diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f658412b..cb67a307 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 branches: - dev + - feat/add-lidarr-download-config pull_request: branches: ['*'] workflow_dispatch: @@ -133,7 +134,7 @@ jobs: build-dev: name: Build/publish dev image runs-on: ubuntu-latest - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/feat/add-lidarr-download-config' permissions: contents: read packages: write diff --git a/sample.env b/sample.env index 68511ba1..a35c6972 100644 --- a/sample.env +++ b/sample.env @@ -103,6 +103,17 @@ LIBRARY_NAME= # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella +# === Lidarr Configuration === + +# LIDARR_API_KEY= +# LIDARR_RETRY= +# LIDARR_DL_ATTEMPTS= +# LIDARR_DIR= +# MIGRATE_DOWNLOADS= +# LIDARR_TIMEOUT= +# LIDARR_SCHEME= +# LIDARR_URL= + # === Metadata / Formatting === # Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true) diff --git a/src/config/config.go b/src/config/config.go index 512c6a93..48f58719 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -99,6 +99,7 @@ type DownloadConfig struct { Youtube Youtube YoutubeMusic YoutubeMusic Slskd Slskd + Lidarr Lidarr ExcludeLocal bool DownloadLimiter int `env:"DOWNLOAD_LIMITER" env-default:"1"` // rate limit download operations OverwriteMetadata bool `env:"OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads @@ -151,10 +152,27 @@ type SlskdMon struct { Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` } +type Lidarr struct { + APIKey string `env:"LIDARR_API_KEY"` + Retry int `env:"LIDARR_RETRY" env-default:"8"` // Number of times to check search status before skipping the track + LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"` + MigrateDL bool `env:"LIDARR_MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir + Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"` + URL string `env:"LIDARR_URL"` + RootFolder string `env:"LIDARR_ROOT_FOLDER" env-default:"Music"` // Root folder name to use in Lidarr + Filters Filters + MonitorConfig LidarrMon +} + +type LidarrMon struct { + Interval int `env:"LIDARR_MONITOR_INTERVAL" env-default:"1"` + Duration int `env:"LIDARR_MONITOR_DURATION" env-default:"20"` +} + type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` ArtistBlacklist []string `env:"ARTIST_BLACKLIST"` - Listenbrainz Listenbrainz + Listenbrainz Listenbrainz } type Listenbrainz struct { Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` @@ -224,6 +242,7 @@ func (cfg *Config) CommonFixes() { cfg.DownloadCfg.Youtube.CoversDir = filepath.Join(filepath.Dir(cfg.ServerCfg.WebDataDir), "cache", "covers") cfg.ClientCfg.URL = fixBaseURL(cfg.ClientCfg.URL) cfg.DownloadCfg.Slskd.URL = fixBaseURL(cfg.DownloadCfg.Slskd.URL) + cfg.DownloadCfg.Lidarr.URL = fixBaseURL(cfg.DownloadCfg.Lidarr.URL) cfg.NormalizeDir() } @@ -232,6 +251,7 @@ func (cfg *Config) NormalizeDir() { cfg.ClientCfg.PlaylistDir = fixDir(cfg.ClientCfg.PlaylistDir) } cfg.DownloadCfg.Slskd.SlskdDir = fixDir(cfg.DownloadCfg.Slskd.SlskdDir) + cfg.DownloadCfg.Lidarr.LidarrDir = fixDir(cfg.DownloadCfg.Lidarr.LidarrDir) cfg.DownloadCfg.DownloadDir = fixDir(cfg.DownloadCfg.DownloadDir) } diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index c7f3509d..338b8743 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -37,7 +37,7 @@ type Metadata struct { Artist struct { ArtistCreditID int `json:"artist_credit_id"` Artists []struct { - ArtistMbid string `json:"artist_mbid"` + MusicBrainzArtistID string `json:"artist_mbid"` BeginYear int `json:"begin_year"` EndYear int `json:"end_year,omitempty"` JoinPhrase string `json:"join_phrase"` @@ -121,7 +121,7 @@ type Exploration struct { AdditionalMetadata struct { Artists []struct { ArtistCreditName string `json:"artist_credit_name"` - ArtistMbid string `json:"artist_mbid"` + MusicBrainzArtistID string `json:"artist_mbid"` JoinPhrase string `json:"join_phrase"` } `json:"artists"` CaaID int64 `json:"caa_id"` @@ -360,7 +360,7 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T MusicBrainzTrackID: mbTrackID, MusicBrainzAlbumID: rel.CaaReleaseMbid, MusicBrainzReleaseGroupID: rel.ReleaseGroupMbid, - MusicBrainzArtistID: recArtists[0].ArtistMbid, + MusicBrainzArtistID: recArtists[0].MusicBrainzArtistID, }) } @@ -409,7 +409,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( mainArtist := recording.Artist.Name mainArtistID := "" if len(recording.Artist.Artists) > 0 { - mainArtistID = recording.Artist.Artists[0].ArtistMbid + mainArtistID = recording.Artist.Artists[0].MusicBrainzArtistID } recArtists := recording.Artist.Artists @@ -563,7 +563,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( if len(recArtists) == 0 { return "" } - return recArtists[0].ArtistMbid + return recArtists[0].MusicBrainzArtistID }(), } } @@ -705,9 +705,9 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri recordingMBID = parts[len(parts)-1] } - artistMBID := "" + MusicBrainzArtistID := "" if len(trackMeta.Artists) > 0 { - artistMBID = trackMeta.Artists[0].ArtistMbid + MusicBrainzArtistID = trackMeta.Artists[0].MusicBrainzArtistID } tracks = append(tracks, &models.Track{ @@ -719,7 +719,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri Duration: track.Duration, CoverURL: coverURL, MusicBrainzTrackID: recordingMBID, - MusicBrainzArtistID: artistMBID, + MusicBrainzArtistID: MusicBrainzArtistID, MusicBrainzAlbumID: trackMeta.CaaReleaseMbid, }) } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 9451f66f..684434c5 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -44,6 +44,10 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL slskdClient := NewSlskd(cfg.Slskd, cfg.DownloadDir) slskdClient.AddHeader() downloader = append(downloader, slskdClient) + case "lidarr": + lidarrClient := NewLidarr(cfg.Lidarr, cfg.DownloadDir) + lidarrClient.AddHeader() + downloader = append(downloader, lidarrClient) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } @@ -124,6 +128,9 @@ func (c *DownloadClient) needsDownloadDir() bool { return true } } + if c.Cfg.Lidarr.MigrateDL { + return c.Cfg.Lidarr.MigrateDL + } return c.Cfg.Slskd.MigrateDL } diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go new file mode 100644 index 00000000..2959462c --- /dev/null +++ b/src/downloader/lidarr.go @@ -0,0 +1,519 @@ +package downloader + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "slices" + "strconv" + "strings" + "time" + + cfg "explo/src/config" + "explo/src/models" + "explo/src/util" +) + +type Lidarr struct { + Headers map[string]string + DownloadDir string + HttpClient *util.HttpClient + Cfg cfg.Lidarr +} + +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + ArtistID int `json:"artistId"` + ForeignAlbumID string `json:"foreignAlbumId"` +} + +type LidarrTrack struct { + ArtistID int `json:"artistId"` + ForeignTrackID string `json:"foreignTrackId"` + ForeignRecordingID string `json:"foreignRecordingId"` + TrackFileID int `json:"trackFileId"` + AlbumID int `json:"albumId"` + Explicit bool `json:"explicit"` + AbsoluteTrackNumber int `json:"absoluteTrackNumber"` + TrackNumber string `json:"trackNumber"` + Title string `json:"title"` + Duration int `json:"duration"` // In milliseconds + MediumNumber int `json:"mediumNumber"` + HasFile bool `json:"hasFile"` + ID int `json:"id"` +} + +type LidarrQueue struct { + TotalRecords int `json:"totalRecords"` + Records []LidarrQueueItem `json:"records"` +} + +type LidarrQueueArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + Album LidarrQueueAlbum `json:"album"` +} + +type LidarrQueueAlbum struct { + ForeignAlbumID string `json:"foreignAlbumId"` +} + +type LidarrQueueItem struct { + ArtistID int `json:"artistId"` + AlbumID int `json:"albumId"` + Size int64 `json:"size"` + Title string `json:"title"` + SizeLeft int64 `json:"sizeleft"` + TimeLeft string `json:"timeleft"` // duration string like "00:00:00" + EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"` + Added time.Time `json:"added"` + Status string `json:"status"` + ID int `json:"id"` + Artist []LidarrQueueArtist `json:"artist"` +} + +type LidarrHistory struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + SortKey string `json:"sortKey"` + SortDirection string `json:"sortDirection"` + TotalRecords int `json:"totalRecords"` + Records []LidarrRecord `json:"records"` +} + +type LidarrRecord struct { + AlbumID int `json:"albumId"` + ArtistID int `json:"artistId"` + TrackID int `json:"trackId"` + SourceTitle string `json:"sourceTitle"` + QualityCutoffNotMet bool `json:"qualityCutoffNotMet"` + Data Data `json:"data"` + Track LidarrTrack `json:"track"` + ID int `json:"id"` +} + +type Data struct { + FileID string `json:"fileId"` + DroppedPath string `json:"droppedPath"` + ImportedPath string `json:"importedPath"` + DownloadClient string `json:"downloadClient"` + ReleaseGroup any `json:"releaseGroup"` + Size string `json:"size"` + IndexerFlags string `json:"indexerFlags"` +} + +type RootFolder struct { + Name string `json:"name"` + Path string `json:"path"` + DefaultMetadataProfileId int `json:"defaultMetadataProfileId"` + DefaultQualityProfileId int `json:"defaultQualityProfileId"` +} + +type AddOptions struct { + SearchForNewAlbum bool `json:"searchForNewAlbum"` +} + +type LidarrCommands []struct { + Name string `json:"name"` + CommandName string `json:"commandName"` + Message string `json:"message,omitempty"` + Body CommandsBody `json:"body,omitempty"` + Priority string `json:"priority"` + Status string `json:"status"` + Result string `json:"result"` + Queued time.Time `json:"queued"` + Started time.Time `json:"started,omitempty"` + Trigger string `json:"trigger"` + StateChangeTime time.Time `json:"stateChangeTime,omitempty"` + SendUpdatesToClient bool `json:"sendUpdatesToClient"` + UpdateScheduledTask bool `json:"updateScheduledTask"` + ID int `json:"id"` + Ended time.Time `json:"ended,omitempty"` + Duration string `json:"duration,omitempty"` + LastExecutionTime time.Time `json:"lastExecutionTime,omitempty"` +} + +type CommandsBody struct { + AlbumIds []int `json:"albumIds"` + SendUpdatesToClient bool `json:"sendUpdatesToClient"` + UpdateScheduledTask bool `json:"updateScheduledTask"` + RequiresDiskAccess bool `json:"requiresDiskAccess"` + IsExclusive bool `json:"isExclusive"` + IsTypeExclusive bool `json:"isTypeExclusive"` + IsLongRunning bool `json:"isLongRunning"` + Name string `json:"name"` + Trigger string `json:"trigger"` + SuppressMessages bool `json:"suppressMessages"` +} + +func NewLidarr(cfg cfg.Lidarr, downloadDir string) *Lidarr { // init downloader cfg for lidarr + return &Lidarr{ + Cfg: cfg, + HttpClient: util.NewHttp(util.HttpClientConfig{Timeout: cfg.Timeout}), + DownloadDir: downloadDir, + } +} + +func (c *Lidarr) AddHeader() { + if c.Headers == nil { + c.Headers = make(map[string]string) + } + c.Headers["X-API-Key"] = c.Cfg.APIKey +} + +func (c *Lidarr) GetConf() (MonitorConfig, error) { + return MonitorConfig{ + CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute, + MonitorDuration: time.Duration(c.Cfg.MonitorConfig.Duration) * time.Minute, + MigrateDownload: c.Cfg.MigrateDL, + ToDir: c.DownloadDir, + FromDir: c.Cfg.LidarrDir, + Service: "Lidarr", + }, nil +} + +func (c *Lidarr) QueryTrack(track *models.Track) error { + trackDetails := fmt.Sprintf("%s - %s", track.Title, track.Artist) + slog.Info("initiating search", "track", trackDetails) + + if err := c.getReleaseGroupId(track); err != nil { + return fmt.Errorf("failed to get release group id for %s - %s: %w", track.Title, track.Artist, err) + } + + queryURL := fmt.Sprintf("%s/api/v1/album?foreignAlbumId=%s", c.Cfg.URL, track.MusicBrainzReleaseGroupID) + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err != nil { + return fmt.Errorf("failed to check library for album: %w", err) + } + + var libraryAlbums []Album + if err = util.ParseResp(body, &libraryAlbums); err != nil { + return fmt.Errorf("failed to unmarshal library albums: %w", err) + } + + if len(libraryAlbums) == 0 { + slog.Info("album not found in Lidarr library") + return nil + } + + libraryAlbum := libraryAlbums[0] + slog.Info("album found in Lidarr library", "album", libraryAlbum.Title, "id", libraryAlbum.ID) + track.ID = strconv.Itoa(libraryAlbum.ID) + + queryURL = fmt.Sprintf("%s/api/v1/track?artistId=%v&albumId=%v", c.Cfg.URL, libraryAlbum.ArtistID, libraryAlbum.ID) + body, err = c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err != nil { + return fmt.Errorf("failed to check existing tracks: %w", err) + } + + var lidarrTracks []LidarrTrack + if err = util.ParseResp(body, &lidarrTracks); err != nil { + return fmt.Errorf("failed to unmarshal lidarr tracks: %w", err) + } + + for _, t := range lidarrTracks { + if util.ContainsFold(t.Title, track.Title) && t.HasFile { + track.Present = true + slog.Info("track already present in Lidarr", "track", trackDetails) + return nil + } + } + + return nil +} + +func (c Lidarr) GetTrack(track *models.Track) error { + + slog.Info("downloading track", + "title", track.Title, + "artist", track.Artist, + "album", track.Album, + ) + if track.Present { + return nil + } + + if track.ID != "" { + albumID, err := strconv.Atoi(track.ID) + if err != nil { + return fmt.Errorf("invalid lidarr album ID: %w", err) + } + slog.Info("album in library but track missing, triggering search", "album", track.Album, "id", albumID) + payload := map[string]any{ + "name": "AlbumSearch", + "albumIds": []int{albumID}, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal error: %w", err) + } + _, err = c.HttpClient.MakeRequest("POST", fmt.Sprintf("%s/api/v1/command", c.Cfg.URL), bytes.NewReader(body), c.Headers) + if err != nil { + return fmt.Errorf("failed to trigger album search: %w", err) + } + + if err := c.searchStatus(albumID, 1); err != nil { + return fmt.Errorf("album search failed: %w", err) + } + track.File = track.Title + return nil + } + + rootFolder, err := c.getRootDirectory() + if err != nil { + return fmt.Errorf("could not look up root directory: %w", err) + } + + payload := map[string]any{ + "foreignAlbumId": track.MusicBrainzReleaseGroupID, + "monitored": true, + "anyReleaseOk": true, + "artist": map[string]any{ + "qualityProfileId": rootFolder.DefaultQualityProfileId, + "metadataProfileId": rootFolder.DefaultMetadataProfileId, + "foreignArtistId": track.MusicBrainzArtistID, + "rootFolderPath": rootFolder.Path, + }, + "addOptions": AddOptions{ + SearchForNewAlbum: true, + }, + } + + payloadBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal error: %w", err) + } + queryURL := fmt.Sprintf("%s/api/v1/album", c.Cfg.URL) + + body, err := c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(payloadBody), c.Headers) + if err != nil { + if strings.Contains(err.Error(), "got 409") { + slog.Debug("album already in Lidarr, skipping", "album", track.MusicBrainzReleaseGroupID) + return nil + } + return fmt.Errorf("failed to add album: %w", err) + } + var album Album + if err = util.ParseResp(body, &album); err != nil { + return fmt.Errorf("failed to unmarshal lidarr album: %w", err) + } + + if err := c.searchStatus(album.ID, 1); err != nil { + return fmt.Errorf("album search failed: %w", err) + } + + track.ID = strconv.Itoa(album.ID) + track.File = track.CleanTitle + slog.Info("download started", "AlbumID", track.ID) + return nil +} +// Check whether album search is completed +func (c *Lidarr) searchStatus(AlbumID, count int) error { + reqParams := "/api/v1/command" + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParams, nil, c.Headers) + if err != nil { + return err + } + + var commands LidarrCommands + if err := util.ParseResp(body, &commands); err != nil { + return err + } + for _, command := range commands { + if command.Name != "AlbumSearch" || + !slices.Contains(command.Body.AlbumIds, AlbumID) { + continue + } + switch command.Status { + case "completed": + return nil + case "failed", "aborted", "cancelled", "orphaned": + return fmt.Errorf("index search %s, skipping album", command.Status) + } + } + if count >= c.Cfg.Retry { + return fmt.Errorf("search wasn't completed after %d retries, skipping album %d", count, AlbumID) + } + slog.Debug("waiting for Lidarr album search", "albumID", AlbumID, "attempt", count, "maxAttempts", c.Cfg.Retry) + time.Sleep(15 * time.Second) + return c.searchStatus(AlbumID, count+1) +} + +func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + + statuses := make(map[string]FileStatus) + requested := make(map[string]struct{}) + // check for completed downloads and build map of tracks that might be in queue + for _, track := range tracks { + if track.ID == "" || track.Present { + continue + } + + requested[track.ID] = struct{}{} + + file, err := c.checkHistory(*track) + if err != nil { + slog.Warn("failed to check download history", "err", err) + } + + if file != "" { + statuses[track.ID] = FileStatus{ + ID: track.ID, + Filename: file, + State: "Succeeded", + BytesTransferred: 1, + BytesRemaining: 0, + PercentComplete: 100, + QueueID: "", + } + } + + } + var totalPages = 1 + pageSize := 50 + var queues []LidarrQueue + for page := 1; page <= totalPages; page++ { + req := fmt.Sprintf("/api/v1/queue?pageSize=%d&page=%d", pageSize, page) + + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, c.Headers) + if err != nil { + return nil, err + } + + var queue LidarrQueue + if err := util.ParseResp(body, &queue); err != nil { + return nil, err + } + queues = append(queues, queue) + if page == 1 { + totalPages = (queue.TotalRecords + pageSize - 1) / pageSize + } + } + for _, queue := range queues { + for _, record := range queue.Records { + ID := strconv.Itoa(record.AlbumID) + if _, ok := requested[ID]; !ok { + continue + } + if _, e := statuses[ID]; e { + continue + } + + statuses[ID] = FileStatus{ + ID: ID, + State: record.Status, + BytesRemaining: int(record.SizeLeft), + BytesTransferred: int(record.Size - record.SizeLeft), + PercentComplete: percent(record.Size, record.SizeLeft), + QueueID: strconv.Itoa(record.ID), + } + } + } + + return statuses, nil +} + +// Checks Lidarr history to see if album is downloaded. Returns file path if found +func (c *Lidarr) checkHistory(track models.Track) (string, error) { + req := fmt.Sprintf("/api/v1/history?albumId=%s&pageSize=1111", track.ID) + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, c.Headers) + if err != nil { + return "", err + } + var history LidarrHistory + if err := util.ParseResp(body, &history); err != nil { + return "", err + } + + mbTrack := track.MusicBrainzTrackID + mbReleaseTrack := track.MusicBrainzReleaseTrackID + for _, r := range history.Records { + + mbIDMatch := (mbTrack != "" && mbTrack == r.Track.ForeignTrackID) || (mbReleaseTrack != "" && mbReleaseTrack == r.Track.ForeignTrackID) + titleMatch := util.ContainsFold(r.SourceTitle, track.CleanTitle) || util.ContainsFold(r.Track.Title, track.CleanTitle) + if (mbIDMatch || titleMatch) && r.Data.ImportedPath != "" { + return r.Data.ImportedPath, nil + } + } + return "", nil + +} + +func (c Lidarr) getRootDirectory() (*RootFolder, error) { + // Get the defaults from the root dir + queryURL := fmt.Sprintf("%s/api/v1/rootfolder", c.Cfg.URL) + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err != nil { + return nil, fmt.Errorf("failed to lookup root folder: %w", err) + } + + var rootFolders []RootFolder + if err = util.ParseResp(body, &rootFolders); err != nil { + return nil, fmt.Errorf("failed to unmarshal query root folder: %w", err) + } + + if len(rootFolders) == 0 { + return nil, fmt.Errorf("no root folders found in Lidarr") + } + for _, folder := range rootFolders { + if folder.Name == c.Cfg.RootFolder { + return &folder, nil + } + } + return nil, fmt.Errorf("no root folder named '%s' found, please create one in Lidarr or point explo to a correct folder", c.Cfg.RootFolder) +} + +func (c Lidarr) getReleaseGroupId(track *models.Track) error { + if track.MusicBrainzReleaseGroupID != "" { + return nil + } + + escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) + queryURL := fmt.Sprintf("%s/api/v1/album/lookup?term=%s", c.Cfg.URL, escQuery) + + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err != nil { + return fmt.Errorf("failed to lookup album: %w", err) + } + + var albums []Album + if err = util.ParseResp(body, &albums); err != nil { + return fmt.Errorf("failed to unmarshal lookup response: %w", err) + } + + if len(albums) == 0 { + return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) + } + + track.MusicBrainzReleaseGroupID = albums[0].ForeignAlbumID + return nil +} + +func percent(total, remaining int64) float64 { + if total == 0 { + return 0 + } + return float64(total-remaining) / float64(total) * 100 +} + +func (c Lidarr) deleteDownload(ID string) error { + reqParams := fmt.Sprintf("/api/v1/queue/%s", ID) + + time.Sleep(1 * time.Second) + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"?removeFromClient=true", nil, c.Headers); err != nil { + return fmt.Errorf("hard delete failed: %w", err) + } + return nil +} + +func (c *Lidarr) Cleanup(track models.Track, queueID string) error { + if track.Present { // dont clear downloaded files from download client + return nil + } + if err := c.deleteDownload(queueID); err != nil { + slog.Info(fmt.Sprintf("[lidarr] failed to delete download: %v", err)) + } + return nil +} diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 79d10dee..332b6a1d 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -32,6 +32,7 @@ type FileStatus struct { BytesTransferred int `json:"bytesTransferred"` BytesRemaining int `json:"bytesRemaining"` PercentComplete float64 `json:"percentComplete"` + QueueID string `json:"queueID"` } func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) error { @@ -51,6 +52,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err if err != nil { return fmt.Errorf("[%s/monitor] error fetching download status: %s", monCfg.Service, err.Error()) } + slog.Debug("fetched download queue", "size", len(statuses)) currentTime := time.Now().Local() @@ -70,7 +72,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err LastUpdated: currentTime, } } - fileStatus, exists := statuses[track.File] + fileStatus, exists := statuses[track.ID] tracker := progressMap[key] if !exists { tracker.Counter++ @@ -82,7 +84,8 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err continue } - if fileStatus.BytesRemaining == 0 || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { + if (fileStatus.BytesRemaining == 0 && fileStatus.BytesTransferred != 0) || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { + track.File = fileStatus.Filename track.Present = true slog.Info("[monitor] file downloaded successfully", "service", monCfg.Service, "file", track.File) var path string @@ -96,7 +99,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } delete(progressMap, key) successDownloads += 1 - if err = m.Cleanup(*track, fileStatus.ID); err != nil { + if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) } continue @@ -110,7 +113,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } else if currentTime.Sub(tracker.LastUpdated) > monCfg.MonitorDuration || fileStatus.State == "Errored" { slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "file", track.File, "duration", monCfg.MonitorDuration) tracker.Skipped = true - if err = m.Cleanup(*track, fileStatus.ID); err != nil { + if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) } continue diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index 5b4756dd..e25dd555 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -118,6 +118,19 @@ var CustomIDRe = regexp.MustCompile(`^custom-[a-z0-9]+$`) VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, }, + { + Key: "LIDARR_URL", Label: "Lidarr URL", + Type: "url", Section: "downloader", + Placeholder: "e.g. http://192.168.1.100:8686", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "lidarr"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "lidarr"}, + }, + { + Key: "LIDARR_API_KEY", Label: "Lidarr API Key", + Type: "text", Section: "downloader", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "lidarr"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "lidarr"}, + }, } */ // Option is a value/label pair for select-type fields. @@ -181,5 +194,6 @@ var AllConfigKeys = []string{ "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "SLSKD_URL", "SLSKD_API_KEY", + "LIDARR_URL", "LIDARR_API_KEY", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", -} \ No newline at end of file +} diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 2744d8ca..79fd6ccd 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -465,7 +465,7 @@ function Collapse({ open, children }) { } // ── Step 3: Downloader ──────────────────────────────────────────────────────── -// Collects download service selection (YouTube, Slskd) and their respective +// Collects download service selection (YouTube, Slskd, Lidarr) and their respective // credentials, download directory, and file format preferences. function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { @@ -479,6 +479,8 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { filterList, slskdUrl, slskdApiKey, + lidarrUrl, + lidarrApiKey, extensions, } = fields; const isLocked = (key) => envSources[key] === "env"; @@ -487,6 +489,8 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { if (!Object.values(dlServices).some(Boolean)) return false; if (dlServices.slskd && (!slskdUrl.trim() || !slskdApiKey.trim())) return false; + if (dlServices.lidarr && (!lidarrUrl.trim() || !lidarrApiKey.trim())) + return false; return true; }; @@ -604,6 +608,69 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { + + {/* Lidarr section */} +
+ By default, lidarr saves tracks to whichever download path is configured in your lidarr instance. +
+