From 0458ba530ca72df6a6dec0aad0fd5c0eba1d34b3 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 29 Apr 2025 20:16:28 -0400 Subject: [PATCH 1/6] Implement initial support for Lidarr downloader Add helper funcs; implement hour cooldown for search Check for rejected releases Add cleanup func and worker Add check to artist adding Mark track as present --- .vscode/settings.json | 5 + go.mod | 2 +- src/config/config.go | 9 ++ src/downloader/downloader.go | 3 +- src/downloader/lidarr.go | 298 +++++++++++++++++++++++++++++++++++ src/main/main.go | 3 +- 6 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/downloader/lidarr.go diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..993db735 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[go]": { + "editor.formatOnSave": false + } +} diff --git a/go.mod b/go.mod index 98ca6664..25431361 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module explo go 1.24.0 -toolchain go1.24.3 +toolchain go1.24.2 require ( github.com/go-co-op/gocron/v2 v2.21.1 diff --git a/src/config/config.go b/src/config/config.go index 3b336681..4b05c12f 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -94,6 +94,7 @@ type DownloadConfig struct { Youtube Youtube YoutubeMusic YoutubeMusic Slskd Slskd + Lidarr Lidarr ExcludeLocal bool KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format @@ -126,6 +127,14 @@ type YoutubeMusic struct { Filters Filters } +type Lidarr struct { + APIKey string `env:"LIDARR_API_KEY"` + Separator string `env:"FILENAME_SEPARATOR" env-default:" "` + FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended"` + Scheme string `env:"LIDARR_SCHEME" env-default:"http"` + URL string `env:"LIDARR_URL"` +} + type Slskd struct { APIKey string `env:"SLSKD_API_KEY"` URL string `env:"SLSKD_URL"` diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 0235c165..1409f774 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -38,11 +38,12 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL slskdClient := NewSlskd(cfg.Slskd, cfg.DownloadDir) slskdClient.AddHeader() downloader = append(downloader, slskdClient) + case "lidarr": + downloader = append(downloader, NewLidarr(cfg.Lidarr, cfg.Discovery, cfg.DownloadDir, httpClient)) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } } - return &DownloadClient{ Cfg: cfg, Downloaders: downloader}, nil diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go new file mode 100644 index 00000000..31196ff3 --- /dev/null +++ b/src/downloader/lidarr.go @@ -0,0 +1,298 @@ +package downloader + +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" + + cfg "explo/src/config" + "explo/src/models" + "explo/src/util" + + "github.com/devopsarr/lidarr-go/lidarr" +) + +type Lidarr struct { + DownloadDir string + HttpClient *util.HttpClient + Client *lidarr.APIClient +} + +func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) *Lidarr { + // Create Lidarr SDK config + apiCfg := lidarr.NewConfiguration() + apiCfg.Host = cfg.URL + apiCfg.Scheme = cfg.Scheme + apiCfg.DefaultHeader["X-Api-Key"] = cfg.APIKey + apiCfg.HTTPClient = httpClient.Client + + client := lidarr.NewAPIClient(apiCfg) + + l := &Lidarr{ + DownloadDir: downloadDir, + HttpClient: httpClient, + Client: client, + } + ctx := context.Background() + l.startCleanupWorker(ctx) + + return l +} + +func (c *Lidarr) QueryTrack(track *models.Track) error { + ctx := context.Background() + + query := fmt.Sprintf("%s %s", track.Artist, track.Album) + albums, _ := c.albumLookup(ctx, query) + + if len(albums) == 0 || len(albums[0].Releases) == 0 { + return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.Artist) + } + + var err error + if albums[0].Id == nil || albums[0].ArtistId == nil { + return fmt.Errorf("album or artist ID was nil for track: %s - %s", track.Title, track.Artist) + } + track.Present, err = c.checkExistingTrack(ctx, *albums[0].Id, *albums[0].ArtistId, track) + if err != nil { + return fmt.Errorf("failed to check existing tracks: %w", err) + } + + return nil +} + +func (c *Lidarr) GetTrack(track *models.Track) error { + ctx := context.Background() + + if track.Present { + return nil + } + // Get the defaults from the root dir + rootFolders, _, err := c.Client.RootFolderAPI.ListRootFolder(ctx).Execute() + if err != nil || len(rootFolders) == 0 { + return fmt.Errorf("failed to get root folders: %w", err) + } + root := rootFolders[0] + + artist, err := c.findArtist(ctx, track.Artist) + if err != nil { + return err + } + if err := c.addArtistIfNeeded(ctx, artist, root); err != nil { + return err + } + + album, err := c.findAlbum(ctx, track.Album) + if err != nil { + return err + } + + chosen, err := c.findReleases(ctx, *album.Id, *album.ArtistId) + if err != nil { + return err + } + + // Start download + release, err := c.createRelease(ctx, chosen) + if err != nil { + return err + } + + track.Present, err = c.checkExistingTrack(ctx, *release.AlbumId.Get(), *release.ArtistId.Get(), track) + if err != nil { + return err + } + + return nil +} + +func (c *Lidarr) checkExistingTrack(ctx context.Context, albumID, artistID int32, track *models.Track) (bool, error) { + log.Print("checking for existing tracks") + tracks, _, err := c.Client.TrackAPI.ListTrack(ctx). + AlbumId(albumID). + ArtistId(artistID). + Execute() + if err != nil { + return false, fmt.Errorf("failed to get album tracks: %w", err) + } + + for _, t := range tracks { + if t.Title.IsSet() && t.Title.Get() != nil && *t.Title.Get() == track.Title { + log.Printf("Track already downloaded: %s", *t.Title.Get()) + return true, nil + } + } + + return false, nil +} + +func (c *Lidarr) findArtist(ctx context.Context, name string) (*lidarr.ArtistResource, error) { + // Lookup Artist + log.Printf("Finding artist: %s", name) + resp, err := c.Client.ArtistLookupAPI.GetArtistLookup(ctx). + Term(name). + Execute() + if err != nil { + return nil, fmt.Errorf("Lidarr artist ID lookup failed with error: %w", err) + } + var artists []lidarr.ArtistResource + if err := json.NewDecoder(resp.Body).Decode(&artists); err != nil { + return nil, fmt.Errorf("failed to decode Lidarr response: %w", err) + } + if len(artists) == 0 { + return nil, fmt.Errorf("no artist found for: %s", name) + } + + return &artists[0], nil +} + +func (c *Lidarr) addArtistIfNeeded(ctx context.Context, artist *lidarr.ArtistResource, root lidarr.RootFolderResource) error { + // Ensure we aren't adding an artist that already exists + if artist.Path.IsSet() && artist.Added != nil && !artist.Added.IsZero() { + log.Printf("Skipping adding already added artist: %v", *artist.ArtistName.Get()) + return nil + } + + a := lidarr.NewArtistResourceWithDefaults() + a.ArtistName = artist.ArtistName + a.ForeignArtistId = artist.ForeignArtistId + a.RootFolderPath = root.Path + a.MetadataProfileId = root.DefaultMetadataProfileId + a.QualityProfileId = root.DefaultQualityProfileId + + _, httpResp, err := c.Client.ArtistAPI.CreateArtist(ctx). + ArtistResource(*a). + Execute() + if err != nil && (httpResp == nil || httpResp.StatusCode != 400) { + return fmt.Errorf("failed to create artist: %w", err) + } + return nil +} + +func (c *Lidarr) albumLookup(ctx context.Context, query string) ([]lidarr.AlbumResource, error) { + resp, err := c.Client.AlbumLookupAPI.GetAlbumLookup(ctx). + Term(query). + Execute() + if err != nil { + return nil, fmt.Errorf("Lidarr album lookup error: %w", err) + } + + var albums []lidarr.AlbumResource + if err := json.NewDecoder(resp.Body).Decode(&albums); err != nil { + return nil, fmt.Errorf("failed to decode Lidarr response: %w", err) + } + return albums, nil +} + +func (c *Lidarr) findAlbum(ctx context.Context, albumName string) (*lidarr.AlbumResource, error) { + log.Print("Finding album: ", albumName) + albums, _ := c.albumLookup(ctx, albumName) + + for _, album := range albums { + // Skip if the album has been searched recently + if album.LastSearchTime.IsSet() { + if time.Since(*album.LastSearchTime.Get()) < time.Hour { + log.Printf("Skipping recently searched album: %s", *album.Title.Get()) + continue + } + } + + // Return the first album that's not recently searched + if album.Id != nil && album.ArtistId != nil { + return &album, nil + } + } + + return nil, fmt.Errorf("no new album found for: %s", albumName) +} + +func (c *Lidarr) findReleases(ctx context.Context, albumID, artistID int32) (*lidarr.ReleaseResource, error) { + log.Print("Finding release") + releases, _, _ := c.Client.ReleaseAPI.ListRelease(ctx). + AlbumId(albumID). + ArtistId(artistID). + Execute() + if len(releases) == 0 { + return nil, fmt.Errorf("no releases found for album ID %d", albumID) + } + + var chosen lidarr.ReleaseResource + found := false + + // Ensure release isn't rejected + for i := range releases { + if releases[i].Rejected != nil && *releases[i].Rejected { + continue + } + chosen = releases[i] + found = true + break + } + + if !found { + return nil, fmt.Errorf("no valid releases found") + } + + chosen.Protocol = nil + + return &chosen, nil +} + +func (c *Lidarr) createRelease(ctx context.Context, chosen *lidarr.ReleaseResource) (*lidarr.ReleaseResource, error) { + log.Print("Starting download") + release, _, err := c.Client.ReleaseAPI.CreateRelease(ctx). + ReleaseResource(*chosen). + Execute() + if err != nil { + return nil, fmt.Errorf("failed to create release: %w", err) + } + body, _ := json.MarshalIndent(release, "", " ") + log.Println("Release created", string(body)) + return release, nil +} + +func (c *Lidarr) startCleanupWorker(ctx context.Context) { + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Println("Cleanup worker stopped") + return + case <-ticker.C: + if err := c.cleanStaleDownloads(ctx); err != nil { + log.Printf("Cleanup worker error: %v", err) + } + } + } + }() +} + +func (c *Lidarr) cleanStaleDownloads(ctx context.Context) error { + queue, _, err := c.Client.QueueAPI.GetQueue(ctx).Execute() + if err != nil { + return fmt.Errorf("failed to get queue: %w", err) + } + records := queue.Records + for _, record := range records { + // skip invalid or incomplete entries + if record.Size == nil || record.Sizeleft == nil { + continue + } + + // Check if download is older than 15 minutes and has not progressed + age := time.Since(*record.Added.Get()) + if age > 15*time.Minute && *record.Size == *record.Sizeleft { + log.Printf("Removing stale download: %s (no progress in %v)", *record.Title.Get(), age) + _, err := c.Client.QueueAPI.DeleteQueue(ctx, *record.Id).Execute() + if err != nil { + log.Printf("Failed to delete record %d from queue: %v", *record.Id, err) + } + } + } + return nil +} diff --git a/src/main/main.go b/src/main/main.go index 1bad8d2e..4aeffae6 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "time" "explo/src/client" "explo/src/config" @@ -86,7 +87,7 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro func initHttpClient() *util.HttpClient { return util.NewHttp(util.HttpClientConfig{ - Timeout: 10, + Timeout: time.Duration(cfg.Timeout) * time.Second, }) } From af5faca59ab686bd6da739d3b121ff29c357ef1e Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Sun, 4 May 2025 12:29:46 -0400 Subject: [PATCH 2/6] Use main artist in search --- src/downloader/lidarr.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 31196ff3..29fa4162 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -44,16 +44,16 @@ func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.H func (c *Lidarr) QueryTrack(track *models.Track) error { ctx := context.Background() - query := fmt.Sprintf("%s %s", track.Artist, track.Album) + query := fmt.Sprintf("%s %s", track.MainArtist, track.Album) albums, _ := c.albumLookup(ctx, query) if len(albums) == 0 || len(albums[0].Releases) == 0 { - return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.Artist) + return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) } var err error if albums[0].Id == nil || albums[0].ArtistId == nil { - return fmt.Errorf("album or artist ID was nil for track: %s - %s", track.Title, track.Artist) + return fmt.Errorf("album or artist ID was nil for track: %s - %s", track.Title, track.MainArtist) } track.Present, err = c.checkExistingTrack(ctx, *albums[0].Id, *albums[0].ArtistId, track) if err != nil { @@ -76,7 +76,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { } root := rootFolders[0] - artist, err := c.findArtist(ctx, track.Artist) + artist, err := c.findArtist(ctx, track.MainArtist) if err != nil { return err } From 4c3964e4c5d4310122042f6782e7714d5e51ac33 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Mon, 5 May 2025 17:20:09 -0400 Subject: [PATCH 3/6] Use raw api calls Add download monitor Add album release to request More updates to lidarr Don't monitor the whole artist Fix rebase More rebase fixes Remove timeout from main.go Fix rebase more Fix rebase more --- go.mod | 2 +- go.sum | 3 + src/config/config.go | 24 ++- src/downloader/lidarr.go | 456 ++++++++++++++++++++++----------------- src/main/main.go | 2 +- 5 files changed, 285 insertions(+), 202 deletions(-) diff --git a/go.mod b/go.mod index 25431361..98ca6664 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module explo go 1.24.0 -toolchain go1.24.2 +toolchain go1.24.3 require ( github.com/go-co-op/gocron/v2 v2.21.1 diff --git a/go.sum b/go.sum index 363c6d62..3d870132 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,7 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -121,6 +122,7 @@ golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= @@ -140,6 +142,7 @@ golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/src/config/config.go b/src/config/config.go index 4b05c12f..87f6791d 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -83,6 +83,22 @@ type AdminCredentials struct { Password string `env:"ADMIN_SYSTEM_PASSWORD"` } +type DiscoveryConfig struct { + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Separator string `env:"FILENAME_SEPARATOR" env-default:" "` + Listenbrainz Listenbrainz +} + +type Lidarr struct { + APIKey string `env:"LIDARR_API_KEY"` + Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + Timeout time.Duration `env:"LIDARR_TIMEOUT" env-default:"20s"` + Scheme string `env:"LIDARR_SCHEME" env-default:"http"` + URL string `env:"LIDARR_URL"` + Filters Filters +} + type SubsonicConfig struct { Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` ID string `env:"CLIENT" env-default:"explo"` @@ -127,14 +143,6 @@ type YoutubeMusic struct { Filters Filters } -type Lidarr struct { - APIKey string `env:"LIDARR_API_KEY"` - Separator string `env:"FILENAME_SEPARATOR" env-default:" "` - FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended"` - Scheme string `env:"LIDARR_SCHEME" env-default:"http"` - URL string `env:"LIDARR_URL"` -} - type Slskd struct { APIKey string `env:"SLSKD_API_KEY"` URL string `env:"SLSKD_URL"` diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 29fa4162..eaa614c8 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -1,259 +1,311 @@ package downloader import ( + "bytes" "context" "encoding/json" "fmt" "log" + "net/url" + "strings" "time" cfg "explo/src/config" "explo/src/models" "explo/src/util" - - "github.com/devopsarr/lidarr-go/lidarr" ) type Lidarr struct { DownloadDir string HttpClient *util.HttpClient - Client *lidarr.APIClient + Cfg cfg.Lidarr } -func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) *Lidarr { - // Create Lidarr SDK config - apiCfg := lidarr.NewConfiguration() - apiCfg.Host = cfg.URL - apiCfg.Scheme = cfg.Scheme - apiCfg.DefaultHeader["X-Api-Key"] = cfg.APIKey - apiCfg.HTTPClient = httpClient.Client +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + Disambiguation string `json:"disambiguation"` + Overview string `json:"overview"` + ArtistID int `json:"artistId"` + ForeignAlbumID string `json:"foreignAlbumId"` + Monitored bool `json:"monitored"` + AnyReleaseOK bool `json:"anyReleaseOk"` + ProfileID int `json:"profileId"` + Duration int `json:"duration"` + AlbumType string `json:"albumType"` + SecondaryTypes []string `json:"secondaryTypes"` + MediumCount int `json:"mediumCount"` + Ratings Ratings `json:"ratings"` + ReleaseDate string `json:"releaseDate"` + Releases []Release `json:"releases"` + Genres []string `json:"genres"` + Media []Media `json:"media"` + Artist Artist `json:"artist"` +} - client := lidarr.NewAPIClient(apiCfg) +type Ratings struct { + Votes int `json:"votes"` + Value float64 `json:"value"` +} - l := &Lidarr{ - DownloadDir: downloadDir, - HttpClient: httpClient, - Client: client, - } - ctx := context.Background() - l.startCleanupWorker(ctx) +type Release struct { + ID int `json:"id"` + AlbumID int `json:"albumId"` + ForeignReleaseID string `json:"foreignReleaseId"` + Title string `json:"title"` + Status string `json:"status"` + Duration int `json:"duration"` + TrackCount int `json:"trackCount"` + Media []Media `json:"media"` + MediumCount int `json:"mediumCount"` + Disambiguation string `json:"disambiguation"` + Country []string `json:"country"` + Label []string `json:"label"` + Format string `json:"format"` + Monitored bool `json:"monitored"` +} - return l +type Media struct { + MediumNumber int `json:"mediumNumber"` + MediumName string `json:"mediumName"` + MediumFormat string `json:"mediumFormat"` } -func (c *Lidarr) QueryTrack(track *models.Track) error { - ctx := context.Background() +type Artist struct { + Status string `json:"status"` + Ended bool `json:"ended"` + ArtistName string `json:"artistName"` + ForeignArtistID string `json:"foreignArtistId"` + ArtistType string `json:"artistType"` + Disambiguation string `json:"disambiguation"` + QualityProfileID int + MetadataProfileID int + RootFolderPath string +} - query := fmt.Sprintf("%s %s", track.MainArtist, track.Album) - albums, _ := c.albumLookup(ctx, query) +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"` + Ratings struct { + Votes int `json:"votes"` + Value float64 `json:"value"` + } `json:"ratings"` + ID int `json:"id"` +} - if len(albums) == 0 || len(albums[0].Releases) == 0 { - return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) - } +type LidarrQueue struct { + TotalRecords int `json:"totalRecords"` + Records []LidarrQueueItem `json:"records"` +} - var err error - if albums[0].Id == nil || albums[0].ArtistId == nil { - return fmt.Errorf("album or artist ID was nil for track: %s - %s", track.Title, track.MainArtist) - } - track.Present, err = c.checkExistingTrack(ctx, *albums[0].Id, *albums[0].ArtistId, track) - if err != nil { - return fmt.Errorf("failed to check existing tracks: %w", err) - } +type LidarrQueueArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + Album LidarrQueueAlbum `json:"album"` +} - return nil +type LidarrQueueAlbum struct { + ForeignAlbumID string `json:"foreignAlbumId"` } -func (c *Lidarr) GetTrack(track *models.Track) error { - ctx := context.Background() +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"` + TrackedDownloadStatus string `json:"trackedDownloadStatus"` + TrackedDownloadState string `json:"trackedDownloadState"` + StatusMessages []string `json:"statusMessages"` + DownloadID string `json:"downloadId"` + Protocol string `json:"protocol"` + DownloadClient string `json:"downloadClient"` + DownloadClientHasPostImportCategory bool `json:"downloadClientHasPostImportCategory"` + Indexer string `json:"indexer"` + TrackFileCount int `json:"trackFileCount"` + TrackHasFileCount int `json:"trackHasFileCount"` + DownloadForced bool `json:"downloadForced"` + ID int64 `json:"id"` + Artist []LidarrQueueArtist `json:"artist"` +} - if track.Present { - return nil - } - // Get the defaults from the root dir - rootFolders, _, err := c.Client.RootFolderAPI.ListRootFolder(ctx).Execute() - if err != nil || len(rootFolders) == 0 { - return fmt.Errorf("failed to get root folders: %w", err) - } - root := rootFolders[0] +type Image struct { + // can leave empty for now +} - artist, err := c.findArtist(ctx, track.MainArtist) - if err != nil { - return err - } - if err := c.addArtistIfNeeded(ctx, artist, root); err != nil { - return err +type AddOptions struct { + SearchForNewAlbum bool `json:"searchForNewAlbum"` +} + +type MinimalArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + QualityProfileID int `json:"qualityProfileId"` + MetadataProfileID int `json:"metadataProfileId"` + Monitored bool `json:"monitored"` + RootFolderPath string `json:"rootFolderPath"` +} + +type AddAlbumRequest struct { + ForeignAlbumID string `json:"foreignAlbumId"` + Images []Image `json:"images"` + Monitored bool `json:"monitored"` + AnyReleaseOk bool `json:"anyReleaseOk"` + Artist MinimalArtist `json:"artist"` + AddOptions AddOptions `json:"addOptions"` + Releases []Release `json:"releases"` +} + +type RootFolder struct { + Path string `json:"path"` + DefaultMetadataProfileId int `json:"defaultMetadataProfileId"` + DefaultQualityProfileId int `json:"defaultQualityProfileId"` +} + +func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) *Lidarr { // init downloader cfg for lidarr + return &Lidarr{ + DownloadDir: downloadDir, + HttpClient: httpClient, + Cfg: cfg, } +} + +func (c *Lidarr) QueryTrack(track *models.Track) error { - album, err := c.findAlbum(ctx, track.Album) + album, err := c.findBestAlbumMatch(track) if err != nil { return err } - chosen, err := c.findReleases(ctx, *album.Id, *album.ArtistId) + queryURL := fmt.Sprintf("%s://%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, album.ArtistID, album.Releases[0].AlbumID) + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { - return err + return fmt.Errorf("failed to check existing tracks: %w", err) } - // Start download - release, err := c.createRelease(ctx, chosen) - if err != nil { - return err + var lidarrTracks []LidarrTrack + if err = util.ParseResp(body, &lidarrTracks); err != nil { + return fmt.Errorf("failed to unmarshal query lidarr tracks body: %w", err) } - track.Present, err = c.checkExistingTrack(ctx, *release.AlbumId.Get(), *release.ArtistId.Get(), track) - if err != nil { - return err + for _, t := range lidarrTracks { + if strings.Contains(t.Title, track.Title) { + if t.HasFile { + track.Present = true + } + } } return nil } -func (c *Lidarr) checkExistingTrack(ctx context.Context, albumID, artistID int32, track *models.Track) (bool, error) { - log.Print("checking for existing tracks") - tracks, _, err := c.Client.TrackAPI.ListTrack(ctx). - AlbumId(albumID). - ArtistId(artistID). - Execute() - if err != nil { - return false, fmt.Errorf("failed to get album tracks: %w", err) - } +func (c *Lidarr) GetTrack(track *models.Track) error { + ctx := context.Background() - for _, t := range tracks { - if t.Title.IsSet() && t.Title.Get() != nil && *t.Title.Get() == track.Title { - log.Printf("Track already downloaded: %s", *t.Title.Get()) - return true, nil - } + if track.Present { + return nil } - return false, nil -} + c.startQueueWorker(ctx, track) -func (c *Lidarr) findArtist(ctx context.Context, name string) (*lidarr.ArtistResource, error) { - // Lookup Artist - log.Printf("Finding artist: %s", name) - resp, err := c.Client.ArtistLookupAPI.GetArtistLookup(ctx). - Term(name). - Execute() + // Get the defaults from the root dir + queryURL := fmt.Sprintf("%s://%s/api/v1/rootfolder?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { - return nil, fmt.Errorf("Lidarr artist ID lookup failed with error: %w", err) - } - var artists []lidarr.ArtistResource - if err := json.NewDecoder(resp.Body).Decode(&artists); err != nil { - return nil, fmt.Errorf("failed to decode Lidarr response: %w", err) + return fmt.Errorf("failed to lookup root folder: %w", err) } - if len(artists) == 0 { - return nil, fmt.Errorf("no artist found for: %s", name) + + var rootFolders []RootFolder + if err = util.ParseResp(body, &rootFolders); err != nil { + return fmt.Errorf("failed to unmarshal query lidarr body: %w", err) } - return &artists[0], nil -} + if len(rootFolders) == 0 { + return fmt.Errorf("no root folders found in Lidarr") + } + rootFolder := rootFolders[0] -func (c *Lidarr) addArtistIfNeeded(ctx context.Context, artist *lidarr.ArtistResource, root lidarr.RootFolderResource) error { - // Ensure we aren't adding an artist that already exists - if artist.Path.IsSet() && artist.Added != nil && !artist.Added.IsZero() { - log.Printf("Skipping adding already added artist: %v", *artist.ArtistName.Get()) - return nil + album, err := c.findBestAlbumMatch(track) + if err != nil { + return err } - a := lidarr.NewArtistResourceWithDefaults() - a.ArtistName = artist.ArtistName - a.ForeignArtistId = artist.ForeignArtistId - a.RootFolderPath = root.Path - a.MetadataProfileId = root.DefaultMetadataProfileId - a.QualityProfileId = root.DefaultQualityProfileId - - _, httpResp, err := c.Client.ArtistAPI.CreateArtist(ctx). - ArtistResource(*a). - Execute() - if err != nil && (httpResp == nil || httpResp.StatusCode != 400) { - return fmt.Errorf("failed to create artist: %w", err) + payload := AddAlbumRequest{ + ForeignAlbumID: track.AlbumMBID, + Images: []Image{}, + Monitored: true, + AnyReleaseOk: true, + Artist: MinimalArtist{ + QualityProfileID: rootFolder.DefaultQualityProfileId, + MetadataProfileID: rootFolder.DefaultMetadataProfileId, + Monitored: false, + ForeignArtistID: track.ArtistMBID, + RootFolderPath: rootFolder.Path, + }, + AddOptions: AddOptions{ + SearchForNewAlbum: true, + }, + Releases: []Release{album.Releases[0]}, } - return nil -} -func (c *Lidarr) albumLookup(ctx context.Context, query string) ([]lidarr.AlbumResource, error) { - resp, err := c.Client.AlbumLookupAPI.GetAlbumLookup(ctx). - Term(query). - Execute() + body, err = json.Marshal(payload) if err != nil { - return nil, fmt.Errorf("Lidarr album lookup error: %w", err) + return fmt.Errorf("marshal error: %w", err) } - - var albums []lidarr.AlbumResource - if err := json.NewDecoder(resp.Body).Decode(&albums); err != nil { - return nil, fmt.Errorf("failed to decode Lidarr response: %w", err) + queryURL = fmt.Sprintf("%s://%s/api/v1/album?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + _, err = c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(body), nil) + if err != nil { + return fmt.Errorf("failed to add album: %w", err) } - return albums, nil + return nil } -func (c *Lidarr) findAlbum(ctx context.Context, albumName string) (*lidarr.AlbumResource, error) { - log.Print("Finding album: ", albumName) - albums, _ := c.albumLookup(ctx, albumName) +func (c *Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { + escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) + queryURL := fmt.Sprintf("%s://%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, escQuery) - for _, album := range albums { - // Skip if the album has been searched recently - if album.LastSearchTime.IsSet() { - if time.Since(*album.LastSearchTime.Get()) < time.Hour { - log.Printf("Skipping recently searched album: %s", *album.Title.Get()) - continue - } - } - - // Return the first album that's not recently searched - if album.Id != nil && album.ArtistId != nil { - return &album, nil - } + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to lookup tracks: %w", err) } - return nil, fmt.Errorf("no new album found for: %s", albumName) -} - -func (c *Lidarr) findReleases(ctx context.Context, albumID, artistID int32) (*lidarr.ReleaseResource, error) { - log.Print("Finding release") - releases, _, _ := c.Client.ReleaseAPI.ListRelease(ctx). - AlbumId(albumID). - ArtistId(artistID). - Execute() - if len(releases) == 0 { - return nil, fmt.Errorf("no releases found for album ID %d", albumID) + var albums []Album + if err = util.ParseResp(body, &albums); err != nil { + return nil, fmt.Errorf("failed to unmarshal query lidarr body: %w", err) } - var chosen lidarr.ReleaseResource - found := false - - // Ensure release isn't rejected - for i := range releases { - if releases[i].Rejected != nil && *releases[i].Rejected { - continue - } - chosen = releases[i] - found = true - break + if len(albums) == 0 { + return nil, fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) } - - if !found { - return nil, fmt.Errorf("no valid releases found") + topMatch := albums[0] + if len(topMatch.Releases) == 0 { + return nil, fmt.Errorf("could not find album releases for track: %s - %s", track.Title, track.MainArtist) } - chosen.Protocol = nil - - return &chosen, nil -} + track.AlbumMBID = topMatch.ForeignAlbumID + track.ArtistMBID = topMatch.Artist.ForeignArtistID -func (c *Lidarr) createRelease(ctx context.Context, chosen *lidarr.ReleaseResource) (*lidarr.ReleaseResource, error) { - log.Print("Starting download") - release, _, err := c.Client.ReleaseAPI.CreateRelease(ctx). - ReleaseResource(*chosen). - Execute() - if err != nil { - return nil, fmt.Errorf("failed to create release: %w", err) + if topMatch.Releases[0].ID == 0 || topMatch.ArtistID == 0 { + return nil, fmt.Errorf("invalid album or artist ID for track: %s - %s", track.Title, track.MainArtist) } - body, _ := json.MarshalIndent(release, "", " ") - log.Println("Release created", string(body)) - return release, nil + + return &topMatch, nil } -func (c *Lidarr) startCleanupWorker(ctx context.Context) { +func (c *Lidarr) startQueueWorker(ctx context.Context, track *models.Track) { go func() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() @@ -261,36 +313,56 @@ func (c *Lidarr) startCleanupWorker(ctx context.Context) { for { select { case <-ctx.Done(): - log.Println("Cleanup worker stopped") + log.Println("Queue worker stopped") return case <-ticker.C: - if err := c.cleanStaleDownloads(ctx); err != nil { - log.Printf("Cleanup worker error: %v", err) + if err := c.monitorQueue(track); err != nil { + log.Printf("Queue worker error: %v", err) } } } }() } -func (c *Lidarr) cleanStaleDownloads(ctx context.Context) error { - queue, _, err := c.Client.QueueAPI.GetQueue(ctx).Execute() +func (c *Lidarr) monitorQueue(track *models.Track) error { + queryURL := fmt.Sprintf("%s://%s/api/v1/queue?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { - return fmt.Errorf("failed to get queue: %w", err) + return fmt.Errorf("failed to lookup tracks: %w", err) + } + + var queue LidarrQueue + if err = util.ParseResp(body, &queue); err != nil { + return fmt.Errorf("failed to unmarshal query lidarr body: %w", err) } - records := queue.Records - for _, record := range records { + + for _, record := range queue.Records { // skip invalid or incomplete entries - if record.Size == nil || record.Sizeleft == nil { + if record.Size == 0 || record.SizeLeft == 0 { continue } // Check if download is older than 15 minutes and has not progressed - age := time.Since(*record.Added.Get()) - if age > 15*time.Minute && *record.Size == *record.Sizeleft { - log.Printf("Removing stale download: %s (no progress in %v)", *record.Title.Get(), age) - _, err := c.Client.QueueAPI.DeleteQueue(ctx, *record.Id).Execute() + age := time.Since(record.Added) + + if age > 15*time.Minute && record.Size == record.SizeLeft { + log.Printf("Removing stale download: %s (no progress in %v)", record.Title, age) + + deleteURL := fmt.Sprintf("%s://%s/api/v1/queue/%v?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, record.ID, c.Cfg.APIKey) + + _, err = c.HttpClient.MakeRequest("DELETE", deleteURL, nil, nil) if err != nil { - log.Printf("Failed to delete record %d from queue: %v", *record.Id, err) + return fmt.Errorf("failed to delete record %d from queue: %v", record.ID, err) + } + continue + } + + if record.SizeLeft == 0 && record.TrackHasFileCount > 0 { + log.Printf("Marking downloaded tracks from album %d as present", record.AlbumID) + + if track.Album == record.Artist[0].Album.ForeignAlbumID { + track.Present = true } } } diff --git a/src/main/main.go b/src/main/main.go index 4aeffae6..7fffa45c 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -87,7 +87,7 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro func initHttpClient() *util.HttpClient { return util.NewHttp(util.HttpClientConfig{ - Timeout: time.Duration(cfg.Timeout) * time.Second, + Timeout: 10, }) } From 56224e9fa27232ac6c74f08278248e043271ca05 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 22 Jul 2025 02:57:15 -0400 Subject: [PATCH 4/6] DRY downloader checker Fix post rebase Remove redeclarations Update sample env; reorganize Add PUID/GID support to Docker container Fixup rebase Update lidarr monitoring Fix config Fix ReadEnv rebase Fixup rebase Restore a couple files Unformat config Ignore vscode Restore downloader Add fields to type Make lidarr timeout an int Fix env reading Remove scheme Fix cleanup tasks Use track title Structure logging Fixup rebase Cut down on changes Fix linting issues --- .vscode/settings.json | 5 -- go.sum | 1 - sample.env | 11 +++ src/config/config.go | 17 ++-- src/discovery/listenbrainz.go | 16 ++-- src/downloader/downloader.go | 5 +- src/downloader/lidarr.go | 152 ++++++++++++++++++++-------------- src/main/main.go | 1 - 8 files changed, 122 insertions(+), 86 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 993db735..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "[go]": { - "editor.formatOnSave": false - } -} diff --git a/go.sum b/go.sum index 3d870132..3d6ef731 100644 --- a/go.sum +++ b/go.sum @@ -142,7 +142,6 @@ golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/sample.env b/sample.env index 40cf2dae..97c6117b 100644 --- a/sample.env +++ b/sample.env @@ -93,6 +93,17 @@ YOUTUBE_API_KEY= # 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 87f6791d..38fb3bc1 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -83,20 +83,21 @@ type AdminCredentials struct { Password string `env:"ADMIN_SYSTEM_PASSWORD"` } -type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` - Separator string `env:"FILENAME_SEPARATOR" env-default:" "` - Listenbrainz Listenbrainz -} - type Lidarr struct { APIKey string `env:"LIDARR_API_KEY"` Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track - Timeout time.Duration `env:"LIDARR_TIMEOUT" env-default:"20s"` - Scheme string `env:"LIDARR_SCHEME" env-default:"http"` + LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"` + MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir + Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"` URL string `env:"LIDARR_URL"` Filters Filters + MonitorConfig LidarrMon +} + +type LidarrMon struct { + Interval time.Duration `env:"SLSKD_MONITOR_INTERVAL" env-default:"1m"` + Duration time.Duration `env:"SLSKD_MONITOR_DURATION" env-default:"15m"` } type SubsonicConfig struct { diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index a4436b91..68ac249d 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"` @@ -347,7 +347,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, }) } @@ -396,7 +396,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 @@ -550,7 +550,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( if len(recArtists) == 0 { return "" } - return recArtists[0].ArtistMbid + return recArtists[0].MusicBrainzArtistID }(), } } @@ -692,9 +692,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{ @@ -706,7 +706,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 1409f774..d6b8e34a 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -39,11 +39,14 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL slskdClient.AddHeader() downloader = append(downloader, slskdClient) case "lidarr": - downloader = append(downloader, NewLidarr(cfg.Lidarr, cfg.Discovery, cfg.DownloadDir, httpClient)) + lidarrClient := NewLidarr(cfg.Lidarr, cfg.DownloadDir) + lidarrClient.AddHeader() + downloader = append(downloader, lidarrClient) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } } + return &DownloadClient{ Cfg: cfg, Downloaders: downloader}, nil diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index eaa614c8..3939a5f3 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -2,13 +2,13 @@ package downloader import ( "bytes" - "context" "encoding/json" "fmt" - "log" + "log/slog" "net/url" "strings" "time" + "strconv" cfg "explo/src/config" "explo/src/models" @@ -16,6 +16,7 @@ import ( ) type Lidarr struct { + Headers map[string]string DownloadDir string HttpClient *util.HttpClient Cfg cfg.Lidarr @@ -174,22 +175,47 @@ type RootFolder struct { DefaultQualityProfileId int `json:"defaultQualityProfileId"` } -func NewLidarr(cfg cfg.Lidarr, discovery, downloadDir string, httpClient *util.HttpClient) *Lidarr { // init downloader cfg for lidarr +func NewLidarr(cfg cfg.Lidarr, downloadDir string) *Lidarr { // init downloader cfg for lidarr return &Lidarr{ - DownloadDir: downloadDir, - HttpClient: httpClient, 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: c.Cfg.MonitorConfig.Interval, + MonitorDuration: c.Cfg.MonitorConfig.Duration, + MigrateDownload: c.Cfg.MigrateDL, + ToDir: c.DownloadDir, + FromDir: c.Cfg.LidarrDir, + Service: "Lidarr", + }, nil } func (c *Lidarr) QueryTrack(track *models.Track) error { + slog.Debug("querying track", + "title", track.Title, + "artist", track.Artist, + "album", track.Album, + ) + slog.Debug(fmt.Sprintf("looking for track %s by %s on album %s", track.Title, track.Artist, track.Album)) + album, err := c.findBestAlbumMatch(track) if err != nil { return err } - queryURL := fmt.Sprintf("%s://%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, album.ArtistID, album.Releases[0].AlbumID) + queryURL := fmt.Sprintf("%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.URL, c.Cfg.APIKey, album.ArtistID, album.Releases[0].AlbumID) body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { return fmt.Errorf("failed to check existing tracks: %w", err) @@ -211,17 +237,19 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { return nil } -func (c *Lidarr) GetTrack(track *models.Track) error { - ctx := context.Background() +func (c Lidarr) GetTrack(track *models.Track) error { + slog.Debug("downloading track", + "title", track.Title, + "artist", track.Artist, + "album", track.Album, + ) if track.Present { return nil } - c.startQueueWorker(ctx, track) - // Get the defaults from the root dir - queryURL := fmt.Sprintf("%s://%s/api/v1/rootfolder?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + queryURL := fmt.Sprintf("%s/api/v1/rootfolder?apiKey=%s", c.Cfg.URL, c.Cfg.APIKey) body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { @@ -244,7 +272,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { } payload := AddAlbumRequest{ - ForeignAlbumID: track.AlbumMBID, + ForeignAlbumID: track.MusicBrainzAlbumID, Images: []Image{}, Monitored: true, AnyReleaseOk: true, @@ -252,7 +280,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { QualityProfileID: rootFolder.DefaultQualityProfileId, MetadataProfileID: rootFolder.DefaultMetadataProfileId, Monitored: false, - ForeignArtistID: track.ArtistMBID, + ForeignArtistID: track.MusicBrainzArtistID, RootFolderPath: rootFolder.Path, }, AddOptions: AddOptions{ @@ -265,7 +293,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { if err != nil { return fmt.Errorf("marshal error: %w", err) } - queryURL = fmt.Sprintf("%s://%s/api/v1/album?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) + queryURL = fmt.Sprintf("%s/api/v1/album?apiKey=%s", c.Cfg.URL, c.Cfg.APIKey) _, err = c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(body), nil) if err != nil { return fmt.Errorf("failed to add album: %w", err) @@ -273,9 +301,9 @@ func (c *Lidarr) GetTrack(track *models.Track) error { return nil } -func (c *Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { +func (c Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) - queryURL := fmt.Sprintf("%s://%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey, escQuery) + queryURL := fmt.Sprintf("%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.URL, c.Cfg.APIKey, escQuery) body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) if err != nil { @@ -295,8 +323,8 @@ func (c *Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { return nil, fmt.Errorf("could not find album releases for track: %s - %s", track.Title, track.MainArtist) } - track.AlbumMBID = topMatch.ForeignAlbumID - track.ArtistMBID = topMatch.Artist.ForeignArtistID + track.MusicBrainzAlbumID = topMatch.ForeignAlbumID + track.MusicBrainzArtistID = topMatch.Artist.ForeignArtistID if topMatch.Releases[0].ID == 0 || topMatch.ArtistID == 0 { return nil, fmt.Errorf("invalid album or artist ID for track: %s - %s", track.Title, track.MainArtist) @@ -305,66 +333,66 @@ func (c *Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { return &topMatch, nil } -func (c *Lidarr) startQueueWorker(ctx context.Context, track *models.Track) { - go func() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Println("Queue worker stopped") - return - case <-ticker.C: - if err := c.monitorQueue(track); err != nil { - log.Printf("Queue worker error: %v", err) - } - } - } - }() -} -func (c *Lidarr) monitorQueue(track *models.Track) error { - queryURL := fmt.Sprintf("%s://%s/api/v1/queue?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, c.Cfg.APIKey) +func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + req := fmt.Sprintf("/api/v1/queue?apiKey=%s", c.Cfg.APIKey) - body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, nil) if err != nil { - return fmt.Errorf("failed to lookup tracks: %w", err) + return nil, err } var queue LidarrQueue - if err = util.ParseResp(body, &queue); err != nil { - return fmt.Errorf("failed to unmarshal query lidarr body: %w", err) + if err := util.ParseResp(body, &queue); err != nil { + return nil, err } + statuses := make(map[string]FileStatus) + for _, record := range queue.Records { - // skip invalid or incomplete entries - if record.Size == 0 || record.SizeLeft == 0 { - continue + // MVP assumption: record.Title matches track.File closely enough + statuses[record.Title] = FileStatus{ + ID: strconv.FormatInt(record.ID, 10), + State: record.Status, + BytesRemaining: int(record.SizeLeft), + BytesTransferred: int(record.Size - record.SizeLeft), + PercentComplete: percent(record.Size, record.SizeLeft), } + } - // Check if download is older than 15 minutes and has not progressed - age := time.Since(record.Added) + if len(statuses) == 0 { + return nil, fmt.Errorf("no queue items found") + } - if age > 15*time.Minute && record.Size == record.SizeLeft { - log.Printf("Removing stale download: %s (no progress in %v)", record.Title, age) + return statuses, nil +} - deleteURL := fmt.Sprintf("%s://%s/api/v1/queue/%v?apiKey=%s", c.Cfg.Scheme, c.Cfg.URL, record.ID, c.Cfg.APIKey) +func percent(total, remaining int64) float64 { + if total == 0 { + return 0 + } + return float64(total-remaining) / float64(total) * 100 +} - _, err = c.HttpClient.MakeRequest("DELETE", deleteURL, nil, nil) - if err != nil { - return fmt.Errorf("failed to delete record %d from queue: %v", record.ID, err) - } - continue - } +func (c Lidarr) deleteDownload(ID string) error { + reqParams := fmt.Sprintf("/api/v1/queue/%s?apiKey=%s", ID, c.Cfg.APIKey) - if record.SizeLeft == 0 && record.TrackHasFileCount > 0 { - log.Printf("Marking downloaded tracks from album %d as present", record.AlbumID) + // cancel download + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"/removeFromClient=false", nil, nil); err != nil { + return fmt.Errorf("soft delete failed: %w", err) + } + time.Sleep(1 * time.Second) // Small buffer between soft and hard delete + // delete download + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"/removeFromClient=true", nil, nil); err != nil { + return fmt.Errorf("hard delete failed: %w", err) + } - if track.Album == record.Artist[0].Album.ForeignAlbumID { - track.Present = true - } - } + return nil +} + +func (c *Lidarr) Cleanup(track models.Track, fileID string) error { + if err := c.deleteDownload(fileID); err != nil { + slog.Debug(fmt.Sprintf("[lidarr] failed to delete download: %v", err)) } return nil } diff --git a/src/main/main.go b/src/main/main.go index 7fffa45c..1bad8d2e 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -11,7 +11,6 @@ import ( "os" "path/filepath" "strings" - "time" "explo/src/client" "explo/src/config" From f3c42ac62d953d9461b175f001316f14685b0109 Mon Sep 17 00:00:00 2001 From: Avery Date: Thu, 4 Jun 2026 11:24:16 -0400 Subject: [PATCH 5/6] Update gui to include lidarr Build test branch Update condition Fix request calls Fix the linter Simplify api calls Debug Try to search on add Remove old structs; update querytrack Debug and comment out helper func More debugging Add new helper func Fix call; add debug Use release group as id Debug More debugging Add release group is helper Get release group --- .github/workflows/release.yml | 3 +- src/config/config.go | 52 +-- src/downloader/downloader.go | 3 + src/downloader/lidarr.go | 302 +++++++----------- src/web/backend/defs.go | 14 + src/web/backend/server.go | 45 +-- src/web/frontend/src/components/Wizard.jsx | 73 ++++- src/web/frontend/src/components/ui/common.jsx | 1 + src/web/sample.env | 16 + 9 files changed, 273 insertions(+), 236 deletions(-) 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/src/config/config.go b/src/config/config.go index 38fb3bc1..abda1827 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -83,23 +83,6 @@ type AdminCredentials struct { Password string `env:"ADMIN_SYSTEM_PASSWORD"` } -type Lidarr struct { - APIKey string `env:"LIDARR_API_KEY"` - Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track - DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track - LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"` - MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir - Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"` - URL string `env:"LIDARR_URL"` - Filters Filters - MonitorConfig LidarrMon -} - -type LidarrMon struct { - Interval time.Duration `env:"SLSKD_MONITOR_INTERVAL" env-default:"1m"` - Duration time.Duration `env:"SLSKD_MONITOR_DURATION" env-default:"15m"` -} - type SubsonicConfig struct { Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` ID string `env:"CLIENT" env-default:"explo"` @@ -161,18 +144,35 @@ 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:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"` + MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir + Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"` + URL string `env:"LIDARR_URL"` + Filters Filters + MonitorConfig LidarrMon +} + +type LidarrMon struct { + Interval time.Duration `env:"LIDARR_MONITOR_INTERVAL" env-default:"1m"` + Duration time.Duration `env:"LIDARR_MONITOR_DURATION" env-default:"15m"` +} + 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"` - User string `env:"LISTENBRAINZ_USER"` - ImportPlaylist string - SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` - CoverArtSize string `env:"COVER_ART_SIZE" env-default:"250"` - EnrichTrackMetadata bool `env:"ENRICH_TRACK_METADATA" env-default:"false"` + Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` + User string `env:"LISTENBRAINZ_USER"` + ImportPlaylist string + SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` + CoverArtSize string `env:"COVER_ART_SIZE" env-default:"250"` + EnrichTrackMetadata bool `env:"ENRICH_TRACK_METADATA" env-default:"false"` } type NotifyConfig struct { @@ -233,6 +233,7 @@ func (cfg *Config) CommonFixes() { cfg.DownloadCfg.Youtube.CoversDir = filepath.Join(filepath.Dir(cfg.Flags.CfgPath), "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() } @@ -241,6 +242,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/downloader/downloader.go b/src/downloader/downloader.go index d6b8e34a..4bbd70cc 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -105,6 +105,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 index 3939a5f3..f9c8f650 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -6,9 +6,9 @@ import ( "fmt" "log/slog" "net/url" + "strconv" "strings" "time" - "strconv" cfg "explo/src/config" "explo/src/models" @@ -23,65 +23,10 @@ type Lidarr struct { } type Album struct { - ID int `json:"id"` - Title string `json:"title"` - Disambiguation string `json:"disambiguation"` - Overview string `json:"overview"` - ArtistID int `json:"artistId"` - ForeignAlbumID string `json:"foreignAlbumId"` - Monitored bool `json:"monitored"` - AnyReleaseOK bool `json:"anyReleaseOk"` - ProfileID int `json:"profileId"` - Duration int `json:"duration"` - AlbumType string `json:"albumType"` - SecondaryTypes []string `json:"secondaryTypes"` - MediumCount int `json:"mediumCount"` - Ratings Ratings `json:"ratings"` - ReleaseDate string `json:"releaseDate"` - Releases []Release `json:"releases"` - Genres []string `json:"genres"` - Media []Media `json:"media"` - Artist Artist `json:"artist"` -} - -type Ratings struct { - Votes int `json:"votes"` - Value float64 `json:"value"` -} - -type Release struct { - ID int `json:"id"` - AlbumID int `json:"albumId"` - ForeignReleaseID string `json:"foreignReleaseId"` - Title string `json:"title"` - Status string `json:"status"` - Duration int `json:"duration"` - TrackCount int `json:"trackCount"` - Media []Media `json:"media"` - MediumCount int `json:"mediumCount"` - Disambiguation string `json:"disambiguation"` - Country []string `json:"country"` - Label []string `json:"label"` - Format string `json:"format"` - Monitored bool `json:"monitored"` -} - -type Media struct { - MediumNumber int `json:"mediumNumber"` - MediumName string `json:"mediumName"` - MediumFormat string `json:"mediumFormat"` -} - -type Artist struct { - Status string `json:"status"` - Ended bool `json:"ended"` - ArtistName string `json:"artistName"` - ForeignArtistID string `json:"foreignArtistId"` - ArtistType string `json:"artistType"` - Disambiguation string `json:"disambiguation"` - QualityProfileID int - MetadataProfileID int - RootFolderPath string + ID int `json:"id"` + Title string `json:"title"` + ArtistID int `json:"artistId"` + ForeignAlbumID string `json:"foreignAlbumId"` } type LidarrTrack struct { @@ -143,38 +88,16 @@ type LidarrQueueItem struct { Artist []LidarrQueueArtist `json:"artist"` } -type Image struct { - // can leave empty for now -} - -type AddOptions struct { - SearchForNewAlbum bool `json:"searchForNewAlbum"` -} - -type MinimalArtist struct { - ForeignArtistID string `json:"foreignArtistId"` - QualityProfileID int `json:"qualityProfileId"` - MetadataProfileID int `json:"metadataProfileId"` - Monitored bool `json:"monitored"` - RootFolderPath string `json:"rootFolderPath"` -} - -type AddAlbumRequest struct { - ForeignAlbumID string `json:"foreignAlbumId"` - Images []Image `json:"images"` - Monitored bool `json:"monitored"` - AnyReleaseOk bool `json:"anyReleaseOk"` - Artist MinimalArtist `json:"artist"` - AddOptions AddOptions `json:"addOptions"` - Releases []Release `json:"releases"` -} - type RootFolder struct { Path string `json:"path"` DefaultMetadataProfileId int `json:"defaultMetadataProfileId"` DefaultQualityProfileId int `json:"defaultQualityProfileId"` } +type AddOptions struct { + SearchForNewAlbum bool `json:"searchForNewAlbum"` +} + func NewLidarr(cfg cfg.Lidarr, downloadDir string) *Lidarr { // init downloader cfg for lidarr return &Lidarr{ Cfg: cfg, @@ -192,8 +115,8 @@ func (c *Lidarr) AddHeader() { func (c *Lidarr) GetConf() (MonitorConfig, error) { return MonitorConfig{ - CheckInterval: c.Cfg.MonitorConfig.Interval, - MonitorDuration: c.Cfg.MonitorConfig.Duration, + 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, @@ -202,35 +125,48 @@ func (c *Lidarr) GetConf() (MonitorConfig, error) { } func (c *Lidarr) QueryTrack(track *models.Track) error { + trackDetails := fmt.Sprintf("%s - %s", track.Title, track.Artist) + slog.Info("initiating search", "track", trackDetails) - slog.Debug("querying track", - "title", track.Title, - "artist", track.Artist, - "album", track.Album, - ) - slog.Debug(fmt.Sprintf("looking for track %s by %s on album %s", track.Title, track.Artist, track.Album)) + 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) + } - album, err := c.findBestAlbumMatch(track) + 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 err + 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 } - queryURL := fmt.Sprintf("%s/api/v1/track?apiKey=%s&artistId=%v&albumId=%v", c.Cfg.URL, c.Cfg.APIKey, album.ArtistID, album.Releases[0].AlbumID) - body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) + libraryAlbum := libraryAlbums[0] + slog.Info("album found in Lidarr library", "album", libraryAlbum.Title, "id", 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 query lidarr tracks body: %w", err) + return fmt.Errorf("failed to unmarshal lidarr tracks: %w", err) } for _, t := range lidarrTracks { - if strings.Contains(t.Title, track.Title) { - if t.HasFile { - track.Present = true - } + if strings.Contains(t.Title, track.Title) && t.HasFile { + track.Present = true + slog.Info("track already present in Lidarr", "track", trackDetails) + return nil } } @@ -239,105 +175,54 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { func (c Lidarr) GetTrack(track *models.Track) error { - slog.Debug("downloading track", - "title", track.Title, - "artist", track.Artist, - "album", track.Album, + slog.Info("downloading track", + "title", track.Title, + "artist", track.Artist, + "album", track.Album, ) if track.Present { return nil } - // Get the defaults from the root dir - queryURL := fmt.Sprintf("%s/api/v1/rootfolder?apiKey=%s", c.Cfg.URL, c.Cfg.APIKey) - - body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) - if err != nil { - return fmt.Errorf("failed to lookup root folder: %w", err) - } - - var rootFolders []RootFolder - if err = util.ParseResp(body, &rootFolders); err != nil { - return fmt.Errorf("failed to unmarshal query lidarr body: %w", err) - } - - if len(rootFolders) == 0 { - return fmt.Errorf("no root folders found in Lidarr") - } - rootFolder := rootFolders[0] - - album, err := c.findBestAlbumMatch(track) + rootFolder, err := c.getRootDirectory() if err != nil { - return err + return fmt.Errorf("could not look up root directory: %w", err) } - payload := AddAlbumRequest{ - ForeignAlbumID: track.MusicBrainzAlbumID, - Images: []Image{}, - Monitored: true, - AnyReleaseOk: true, - Artist: MinimalArtist{ - QualityProfileID: rootFolder.DefaultQualityProfileId, - MetadataProfileID: rootFolder.DefaultMetadataProfileId, - Monitored: false, - ForeignArtistID: track.MusicBrainzArtistID, - RootFolderPath: rootFolder.Path, + 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{ + "addOptions": AddOptions{ SearchForNewAlbum: true, }, - Releases: []Release{album.Releases[0]}, } - body, err = json.Marshal(payload) + body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal error: %w", err) } - queryURL = fmt.Sprintf("%s/api/v1/album?apiKey=%s", c.Cfg.URL, c.Cfg.APIKey) - _, err = c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(body), nil) + queryURL := fmt.Sprintf("%s/api/v1/album", c.Cfg.URL) + slog.Info("Track struct", track) + slog.Info("request payload", payload) + slog.Info("query url", queryURL) + _, err = c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(body), c.Headers) if err != nil { return fmt.Errorf("failed to add album: %w", err) } return nil } -func (c Lidarr) findBestAlbumMatch(track *models.Track) (*Album, error) { - escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Album, track.MainArtist)) - queryURL := fmt.Sprintf("%s/api/v1/album/lookup?apiKey=%s&term=%s", c.Cfg.URL, c.Cfg.APIKey, escQuery) - - body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, nil) - if err != nil { - return nil, fmt.Errorf("failed to lookup tracks: %w", err) - } - - var albums []Album - if err = util.ParseResp(body, &albums); err != nil { - return nil, fmt.Errorf("failed to unmarshal query lidarr body: %w", err) - } - - if len(albums) == 0 { - return nil, fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) - } - topMatch := albums[0] - if len(topMatch.Releases) == 0 { - return nil, fmt.Errorf("could not find album releases for track: %s - %s", track.Title, track.MainArtist) - } - - track.MusicBrainzAlbumID = topMatch.ForeignAlbumID - track.MusicBrainzArtistID = topMatch.Artist.ForeignArtistID - - if topMatch.Releases[0].ID == 0 || topMatch.ArtistID == 0 { - return nil, fmt.Errorf("invalid album or artist ID for track: %s - %s", track.Title, track.MainArtist) - } - - return &topMatch, nil -} - - func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { - req := fmt.Sprintf("/api/v1/queue?apiKey=%s", c.Cfg.APIKey) + req := "/api/v1/queue" - body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, nil) + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, c.Headers) if err != nil { return nil, err } @@ -360,11 +245,53 @@ func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatu } } - if len(statuses) == 0 { - return nil, fmt.Errorf("no queue items found") + return statuses, 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) } - return statuses, nil + 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") + } + rootFolder := rootFolders[0] + return &rootFolder, nil +} + +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 { @@ -375,24 +302,21 @@ func percent(total, remaining int64) float64 { } func (c Lidarr) deleteDownload(ID string) error { - reqParams := fmt.Sprintf("/api/v1/queue/%s?apiKey=%s", ID, c.Cfg.APIKey) + reqParams := fmt.Sprintf("/api/v1/queue/%s", ID) - // cancel download - if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"/removeFromClient=false", nil, nil); err != nil { + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"?removeFromClient=false", nil, c.Headers); err != nil { return fmt.Errorf("soft delete failed: %w", err) } - time.Sleep(1 * time.Second) // Small buffer between soft and hard delete - // delete download - if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"/removeFromClient=true", nil, nil); err != nil { + 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, fileID string) error { if err := c.deleteDownload(fileID); err != nil { - slog.Debug(fmt.Sprintf("[lidarr] failed to delete download: %v", err)) + slog.Info(fmt.Sprintf("[lidarr] failed to delete download: %v", err)) } return nil } diff --git a/src/web/backend/defs.go b/src/web/backend/defs.go index b5b7b97e..65e8d659 100644 --- a/src/web/backend/defs.go +++ b/src/web/backend/defs.go @@ -111,6 +111,19 @@ package backend 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"}, + }, } */ // FieldDef describes a single configurable env var. @@ -159,5 +172,6 @@ var allConfigKeys = []string{ "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "SLSKD_URL", "SLSKD_API_KEY", + "LIDARR_URL", "LIDARR_API_KEY", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", } diff --git a/src/web/backend/server.go b/src/web/backend/server.go index dcdabff6..174bc175 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -69,11 +69,11 @@ func newManualRunState() manualRunState { } type Server struct { - cfg config.ServerConfig + cfg config.ServerConfig mux *http.ServeMux server *http.Server authStore *AuthStore - cronJobs *Jobs + cronJobs *Jobs sessionManager *SessionManager manualRun manualRunState } @@ -87,25 +87,25 @@ func NewServer(cfg config.ServerConfig) *Server { ) authStore := NewAuthStore( - cfg.Username, - cfg.Password, - sessionManager, -) + cfg.Username, + cfg.Password, + sessionManager, + ) cronJobs := NewJobs() mux := http.NewServeMux() s := &Server{ cfg: cfg, - mux: mux, + mux: mux, server: &http.Server{ Addr: cfg.Port, Handler: sessionManager.Handle(mux), }, - authStore: authStore, - cronJobs: cronJobs, + authStore: authStore, + cronJobs: cronJobs, sessionManager: sessionManager, - manualRun: newManualRunState(), + manualRun: newManualRunState(), } s.registerRoutes() @@ -144,8 +144,13 @@ func checkForUpdate() { c := parseVer(config.Version) newer := false for i := range 3 { - if l[i] > c[i] { newer = true; break } - if l[i] < c[i] { break } + if l[i] > c[i] { + newer = true + break + } + if l[i] < c[i] { + break + } } if newer { slog.Info("new version available!", "latest", release.TagName, "current", config.Version) @@ -181,7 +186,7 @@ func (s *Server) startJobs() { s.cronJobs.Start() } -func(s *Server) PrefetchCovers() { +func (s *Server) PrefetchCovers() { coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") @@ -385,7 +390,7 @@ func parseEnvText(text string) map[string]string { if len(v) >= 2 { if (v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"') { - v = v[1 : len(v)-1] + v = v[1 : len(v)-1] } } out[k] = v @@ -559,7 +564,7 @@ func updateEnvKeys(path string, updates map[string]string, fallback []byte) erro // Append any keys that weren't already in the file for k, v := range updates { if !touched[k] && v != "" { - lines = append(lines, k + "=" + formatEnvValue(v)) + lines = append(lines, k+"="+formatEnvValue(v)) } } @@ -695,7 +700,9 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { FilterList string `json:"filter_list"` SlskdURL string `json:"slskd_url"` SlskdAPIKey string `json:"slskd_api_key"` - Extensions string `json:"extensions"` // slskd + LidarrURL string `json:"lidarr_url"` + LidarrAPIKey string `json:"lidarr_api_key"` + Extensions string `json:"extensions"` // slskd } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -725,8 +732,10 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { "FILTER_LIST": body.FilterList, "SLSKD_URL": body.SlskdURL, "SLSKD_API_KEY": body.SlskdAPIKey, - "EXTENSIONS": body.Extensions, // slskd - "WIZARD_COMPLETE": "true", + "LIDARR_URL": body.LidarrURL, + "LIDARR_API_KEY": body.LidarrAPIKey, + "EXTENSIONS": body.Extensions, // slskd + "WIZARD_COMPLETE": "true", } if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index c5b4a7d8..fea69c5e 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -251,17 +251,18 @@ 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 }) { const { downloadDir, useSubdirectory, migrateDownloads, dlServices, - youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey, extensions } = fields + youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey, lidarrUrl, lidarrApiKey, extensions } = fields const isLocked = key => envSources[key] === 'env' const valid = () => { 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 } @@ -378,6 +379,69 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { + + {/* Lidarr section */} +
+ setField('dlServices', { ...dlServices, lidarr: v })} + name="Lidarr" + desc="Downloads using Lidarr's config · requires a running Lidarr instance" + /> + +
+ + setField('lidarrUrl', e.target.value)} + placeholder="e.g. http://192.168.1.100:5030" disabled={isLocked('LIDARR_URL')} /> + + + setField('lidarrApiKey', e.target.value)} + autoComplete="off" spellCheck={false} disabled={isLocked('LIDARR_API_KEY')} /> + + + setField('extensions', e.target.value)} + placeholder="flac,mp3" autoComplete="off" spellCheck={false} disabled={isLocked('EXTENSIONS')} /> + + {/* Show keyword exclusion when YouTube isn't enabled — otherwise it lives in the YouTube section */} + + + setField('filterList', e.target.value)} + placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} /> + + +
+

+ By default, lidarr saves tracks to whichever download path is configured in your lidarr instance. +

+ setField('migrateDownloads', v)} + disabled={isLocked('MIGRATE_DOWNLOADS')} + desc="Move completed downloads to a separate directory after transfer" + /> +
+ {/* Only show download dir here when YouTube isn't also enabled — otherwise it lives in the YouTube section */} + +
+ + setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} + placeholder="/data/" /> + + setField('useSubdirectory', v)} + disabled={isLocked('USE_SUBDIRECTORY')} + name="Use playlist subfolders" + desc="Create a subfolder per playlist inside the download directory" + /> +
+
+
+
+
@@ -421,12 +485,14 @@ export default function Wizard({ config, envSources, bgUrl, bgLoaded, onBgLoad, downloadDir: config.DOWNLOAD_DIR || '', useSubdirectory: config.USE_SUBDIRECTORY !== 'false', migrateDownloads: config.MIGRATE_DOWNLOADS === 'true', - dlServices: { youtube: s.includes('youtube'), slskd: s.includes('slskd') }, + dlServices: { youtube: s.includes('youtube'), slskd: s.includes('slskd'), lidarr: s.includes('lidarr') }, youtubeApiKey: config.YOUTUBE_API_KEY || '', trackExtension: config.TRACK_EXTENSION || '', filterList: config.FILTER_LIST || '', slskdUrl: config.SLSKD_URL || '', slskdApiKey: config.SLSKD_API_KEY || '', + lidarrUrl: config.LIDARR_URL || '', + lidarrApiKey: config.LIDARR_API_KEY || '', extensions: config.EXTENSIONS || '', } }) @@ -479,6 +545,7 @@ export default function Wizard({ config, envSources, bgUrl, bgLoaded, onBgLoad, migrate_downloads: fields.migrateDownloads, download_services: services, youtube_api_key: fields.youtubeApiKey, track_extension: fields.trackExtension, filter_list: fields.filterList, slskd_url: fields.slskdUrl, slskd_api_key: fields.slskdApiKey, + lidarr_url: fields.lidarrUrl, lidarr_api_key: fields.lidarrApiKey, extensions: fields.extensions, }) onComplete() diff --git a/src/web/frontend/src/components/ui/common.jsx b/src/web/frontend/src/components/ui/common.jsx index d155601b..da84f9ec 100644 --- a/src/web/frontend/src/components/ui/common.jsx +++ b/src/web/frontend/src/components/ui/common.jsx @@ -80,6 +80,7 @@ const KEY_LABELS = { 'duration': 'Duration', 'notify': 'Notify', 'slskd': 'Slskd', + 'lidarr': 'Lidarr', 'youtube': 'YouTube', 'context': 'Context', 'force_refresh': 'Force refresh', diff --git a/src/web/sample.env b/src/web/sample.env index 7b6df352..f7e27274 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -93,6 +93,22 @@ YOUTUBE_API_KEY= # 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 instance address (requires running instance) +# LIDARR_URL= +# Lidarr API key +# LIDARR_API_KEY= +# Whether to move downloads under the DOWNLOAD_DIR or not (default: false) +# MIGRATE_DOWNLOADS=false +# Directory where lidarr downloads tracks (default: /lidarr/) +# PS! This is only needed on the binary version, in docker it's set through volume mapping +# LIDARR_DIR=/lidarr/ +# Number of times to check search status before skipping the track (default: 5) +# LIDARR_RETRY=5 +# Number of download attempts for a track (default: 3) +# LIDARR_DL_ATTEMPTS=3 + # === Metadata / Formatting === # Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true) From 061ca952bb4271cb9f40e6fb00075d201c6f9f67 Mon Sep 17 00:00:00 2001 From: Avery Date: Fri, 5 Jun 2026 14:27:43 -0400 Subject: [PATCH 6/6] Don't add an album that exists Remove debugs Set trackfile after download starts --- src/downloader/lidarr.go | 73 +++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index f9c8f650..08982725 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -42,11 +42,7 @@ type LidarrTrack struct { Duration int `json:"duration"` // In milliseconds MediumNumber int `json:"mediumNumber"` HasFile bool `json:"hasFile"` - Ratings struct { - Votes int `json:"votes"` - Value float64 `json:"value"` - } `json:"ratings"` - ID int `json:"id"` + ID int `json:"id"` } type LidarrQueue struct { @@ -64,28 +60,17 @@ type LidarrQueueAlbum struct { } 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"` - TrackedDownloadStatus string `json:"trackedDownloadStatus"` - TrackedDownloadState string `json:"trackedDownloadState"` - StatusMessages []string `json:"statusMessages"` - DownloadID string `json:"downloadId"` - Protocol string `json:"protocol"` - DownloadClient string `json:"downloadClient"` - DownloadClientHasPostImportCategory bool `json:"downloadClientHasPostImportCategory"` - Indexer string `json:"indexer"` - TrackFileCount int `json:"trackFileCount"` - TrackHasFileCount int `json:"trackHasFileCount"` - DownloadForced bool `json:"downloadForced"` - ID int64 `json:"id"` - Artist []LidarrQueueArtist `json:"artist"` + 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 int64 `json:"id"` + Artist []LidarrQueueArtist `json:"artist"` } type RootFolder struct { @@ -150,6 +135,7 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { 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) @@ -163,7 +149,7 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { } for _, t := range lidarrTracks { - if strings.Contains(t.Title, track.Title) && t.HasFile { + if strings.Contains(strings.ToLower(t.Title), strings.ToLower(track.Title)) && t.HasFile { track.Present = true slog.Info("track already present in Lidarr", "track", trackDetails) return nil @@ -184,6 +170,28 @@ func (c Lidarr) GetTrack(track *models.Track) error { 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) + } + track.File = track.Title + return nil + } + rootFolder, err := c.getRootDirectory() if err != nil { return fmt.Errorf("could not look up root directory: %w", err) @@ -209,13 +217,16 @@ func (c Lidarr) GetTrack(track *models.Track) error { return fmt.Errorf("marshal error: %w", err) } queryURL := fmt.Sprintf("%s/api/v1/album", c.Cfg.URL) - slog.Info("Track struct", track) - slog.Info("request payload", payload) - slog.Info("query url", queryURL) _, err = c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(body), 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) } + track.File = track.Title + slog.Info("download started") return nil }