From 58080f778c49618bdf32ef69b44f5942cb17f61e Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 29 Apr 2025 20:16:28 -0400 Subject: [PATCH 01/39] 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 + src/config/config.go | 9 ++ src/downloader/downloader.go | 3 +- src/downloader/lidarr.go | 298 +++++++++++++++++++++++++++++++++++ src/main/main.go | 3 +- 5 files changed, 316 insertions(+), 2 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/src/config/config.go b/src/config/config.go index 874e56a6..329965b9 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -98,6 +98,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 @@ -133,6 +134,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 9451f66f..fef56ef8 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -44,11 +44,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 6b32e5e3..b96e5423 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strings" + "time" "explo/src/client" "explo/src/config" @@ -89,7 +90,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 d0c80b1b54fdf5d3c88809a57d51629f86343c3e Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Sun, 4 May 2025 12:29:46 -0400 Subject: [PATCH 02/39] 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 2fdc8fa53dff989b8bde010848334230e87b7a45 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Mon, 5 May 2025 17:20:09 -0400 Subject: [PATCH 03/39] 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.sum | 3 + src/config/config.go | 24 ++- src/downloader/lidarr.go | 456 ++++++++++++++++++++++----------------- src/main/main.go | 2 +- 4 files changed, 284 insertions(+), 201 deletions(-) diff --git a/go.sum b/go.sum index 7a155d3e..284a0e34 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 329965b9..913d9a25 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -87,6 +87,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"` @@ -134,14 +150,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 b96e5423..d2c5bee8 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -90,7 +90,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 e9125b79c044603c054c044a5520a702f7f7e5df Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 22 Jul 2025 02:57:15 -0400 Subject: [PATCH 04/39] 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 284a0e34..2e566e94 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 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 913d9a25..6927134e 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -87,20 +87,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 ddb25df8..e7ed020c 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"` @@ -383,7 +383,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, }) } @@ -432,7 +432,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 @@ -581,7 +581,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( if len(recArtists) == 0 { return "" } - return recArtists[0].ArtistMbid + return recArtists[0].MusicBrainzArtistID }(), } } @@ -718,9 +718,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{ @@ -732,7 +732,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 fef56ef8..4078de25 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -45,11 +45,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 d2c5bee8..6b32e5e3 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -12,7 +12,6 @@ import ( "os" "path/filepath" "strings" - "time" "explo/src/client" "explo/src/config" From 55265afc5ebcde33e2bf1e697b79b542b07ab704 Mon Sep 17 00:00:00 2001 From: Avery Date: Thu, 4 Jun 2026 11:24:16 -0400 Subject: [PATCH 05/39] 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 | 40 +- src/downloader/downloader.go | 3 + src/downloader/lidarr.go | 302 +++--- src/web/backend/defs/defs.go | 16 +- src/web/backend/server.go | 944 +++++++++++++++++- src/web/frontend/src/components/Wizard.jsx | 75 +- src/web/frontend/src/components/ui/common.jsx | 1 + src/web/sample.env | 16 + 9 files changed, 1188 insertions(+), 212 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 6927134e..4e13862b 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -87,23 +87,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"` @@ -168,10 +151,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:"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"` @@ -241,6 +241,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() } @@ -249,6 +250,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 4078de25..684434c5 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -128,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 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/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/backend/server.go b/src/web/backend/server.go index 9da7747e..a61c5a02 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -31,11 +31,18 @@ type Server struct { cfg config.ServerConfig mux *http.ServeMux server *http.Server +<<<<<<< HEAD authStore *auth.AuthStore settings *settings.Settings cronJobs *jobs.Jobs manualRun *run.ManualRun customPlaylist *playlist.Playlist +======= + authStore *AuthStore + cronJobs *Jobs + sessionManager *SessionManager + manualRun manualRunState +>>>>>>> f3c42ac (Update gui to include lidarr) } func NewServer(cfg config.ServerConfig) *Server { @@ -46,16 +53,23 @@ func NewServer(cfg config.ServerConfig) *Server { "session", ) +<<<<<<< HEAD authStore := auth.NewAuthStore( +======= + authStore := NewAuthStore( +>>>>>>> f3c42ac (Update gui to include lidarr) cfg.Username, cfg.Password, sessionManager, ) +<<<<<<< HEAD webCfg := app.Config{ WebEnvPath: cfg.WebEnvPath, WebDataDir: cfg.WebDataDir, ExploPath: cfg.ExploPath, } +======= +>>>>>>> f3c42ac (Update gui to include lidarr) settings := settings.NewSettings(webCfg) @@ -72,10 +86,16 @@ func NewServer(cfg config.ServerConfig) *Server { Handler: sessionManager.Handle(mux), }, authStore: authStore, +<<<<<<< HEAD settings: settings, cronJobs: cronJobs, manualRun: manualRun, customPlaylist: playlist, +======= + cronJobs: cronJobs, + sessionManager: sessionManager, + manualRun: newManualRunState(), +>>>>>>> f3c42ac (Update gui to include lidarr) } s.registerRoutes() @@ -159,6 +179,19 @@ func (s *Server) startJobs() { s.cronJobs.Start() } +<<<<<<< HEAD +======= +func (s *Server) PrefetchCovers() { + + coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") + + url := randomLocalCoverHiRes(coversDir) + if url == "" { + fetchSitewideCovers(coversDir) + } +} + +>>>>>>> f3c42ac (Update gui to include lidarr) // spaFS returns the filesystem to serve the frontend from. // When WEB_DEV=true, serves directly from src/web/dist on disk so that // running "npm run build" reflects changes without recompiling the binary. @@ -171,4 +204,913 @@ func spaFS() (fs.FS, []byte) { embedded, _ := fs.Sub(web.DistFiles, "dist") index, _ := fs.ReadFile(embedded, "index.html") return embedded, index -} \ No newline at end of file +<<<<<<< HEAD +} +======= +} + +func (s *Server) registerRoutes() { + distFS, indexHTML := spaFS() + fileServer := http.FileServer(http.FS(distFS)) + + // SPA fallback: serve static assets when they exist, otherwise serve index.html. + s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path != "" { + if _, err := fs.Stat(distFS, path); err == nil { + fileServer.ServeHTTP(w, r) + return + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if _, err := w.Write(indexHTML); err != nil { + slog.Error("failed writing to http", "msg", err.Error()) + } + }) + s.mux.Handle("GET /api/ui/config", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetConfig))) + s.mux.Handle("GET /api/ui/config/raw", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetConfigRaw))) + s.mux.Handle("POST /api/ui/config", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveConfig))) + s.mux.Handle("POST /api/ui/config/reset", s.authStore.RequireAuth(http.HandlerFunc(s.handleResetConfig))) + s.mux.Handle("POST /api/ui/config/schedules", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveSchedule))) + s.mux.Handle("POST /api/ui/wizard/step1", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep1))) + s.mux.Handle("POST /api/ui/wizard/step2", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep2))) + s.mux.Handle("POST /api/ui/wizard/step3", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep3))) + s.mux.Handle("GET /api/ui/browse", s.authStore.RequireAuth(http.HandlerFunc(s.handleBrowse))) + s.mux.Handle("POST /api/ui/run", s.authStore.RequireAuth(http.HandlerFunc(s.handleRun))) + s.mux.Handle("GET /api/ui/run/events", s.authStore.RequireAuth(http.HandlerFunc(s.handleRunEvents))) + s.mux.Handle("POST /api/ui/run/stop", s.authStore.RequireAuth(http.HandlerFunc(s.handleStopRun))) + s.mux.Handle("GET /api/ui/run/status", s.authStore.RequireAuth(http.HandlerFunc(s.handleRunStatus))) + s.mux.Handle("GET /api/ui/logs", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetLog))) + s.mux.Handle("GET /api/ui/playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetPlaylist))) + s.mux.Handle("POST /api/ui/playlists/prefetch", s.authStore.RequireAuth(http.HandlerFunc(s.handlePrefetchCovers))) + s.mux.Handle("GET /api/ui/custom-playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetCustomPlaylists))) + s.mux.Handle("POST /api/ui/custom-playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleImportCustomPlaylist))) + s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.authStore.RequireAuth(http.HandlerFunc(s.handleDeleteCustomPlaylist))) + s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.authStore.RequireAuth(http.HandlerFunc(s.handleRefreshCustomPlaylist))) + s.mux.Handle("POST /api/ui/logout", s.authStore.RequireAuth(http.HandlerFunc(s.handleLogout))) + s.mux.HandleFunc("GET /api/ui/csrf", s.csrfHandler) + s.mux.HandleFunc("POST /api/ui/login", s.handleLogin) + s.mux.HandleFunc("GET /api/ui/auth/status", s.handleAuthStatus) + s.mux.HandleFunc("GET /api/ui/background-art", s.handleBackgroundArt) + s.mux.HandleFunc("GET /api/ui/setup-status", s.handleSetupStatus) + + coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") + s.mux.Handle("/api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) +} + +// ── Logging ──────────────────────────────────────────────────────────────── + +// logPath returns the path to the single rolling log file. +func (s *Server) logPath() string { + return filepath.Join(s.cfg.WebDataDir, "logs", "explo.log") +} + +// initServerLog redirects the default slog handler so all server log output +// goes to both stderr and the rolling log file. +func (s *Server) initServerLog() { + lf, err := s.openRunLog() + if err != nil { + return + } + w := io.MultiWriter(os.Stderr, lf) + slog.SetDefault(slog.New(slog.NewTextHandler(w, nil))) +} + +// openRunLog opens the single rolling log file in append mode. +func (s *Server) openRunLog() (*os.File, error) { + p := s.logPath() + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + return nil, err + } + return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) +} + +// handleSetupStatus returns {"wizard_complete": bool} for first time setups. Public — no auth required. +func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { + wizardComplete := false + if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { + wizardComplete = parseEnvText(string(data))["WIZARD_COMPLETE"] == "true" + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]bool{"wizard_complete": wizardComplete}); err != nil { + slog.Error("failed encoding setup status", "err", err.Error()) + } +} + +func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { + sess := s.sessionManager.GetSession(r) + auth, _ := sess.Get("authenticated").(bool) + if !auth { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + err := http.StatusMethodNotAllowed + http.Error(w, "Invalid request method", err) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + if !s.authStore.CompareCreds(username, password) { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + sess := s.sessionManager.GetSession(r) + sess.Put("authenticated", true) + sess.Put("username", username) + //s.sessionManager.Migrate(sess) + slog.Info("successful login", "user", username) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + sess := s.sessionManager.GetSession(r) + sess.Delete("authenticated") + sess.Delete("username") + w.WriteHeader(http.StatusOK) +} + +// handleGetLog returns the contents of the rolling log file. +func (s *Server) handleGetLog(w http.ResponseWriter, r *http.Request) { + data, err := os.ReadFile(s.logPath()) + if err != nil && !os.IsNotExist(err) { + http.Error(w, "failed to read log", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if _, err := w.Write(data); err != nil { + slog.Error("failed writing http response", "msg", err.Error()) + } +} + +func (s *Server) csrfHandler(w http.ResponseWriter, r *http.Request) { + session := s.sessionManager.GetSession(r) + + token, _ := session.Get("csrf_token").(string) + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]string{ + "csrf_token": token, + }); err != nil { + slog.Error("failed encoding token to http", "msg", err.Error()) + } +} + +// ── Config ───────────────────────────────────────────────────────────────── + +// parseEnvText parses key=value lines, ignoring comments, blanks and unquotes variables +// . +func parseEnvText(text string) map[string]string { + out := map[string]string{} + for line := range strings.SplitSeq(text, "\n") { + t := strings.TrimSpace(line) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + k, v, ok := strings.Cut(t, "=") + if !ok { + continue + } + if k = strings.TrimSpace(k); k != "" { + v = strings.TrimSpace(v) + + // unquote if quoted + if len(v) >= 2 { + if (v[0] == '\'' && v[len(v)-1] == '\'') || + (v[0] == '"' && v[len(v)-1] == '"') { + v = v[1 : len(v)-1] + } + } + out[k] = v + } + } + return out +} + +// handleGetConfig returns resolved config as JSON: { values, sources }. +// File keys are checked first because cleanenv sets them as OS env vars on startup, +// so checking os.LookupEnv first would misclassify all file keys as "env". +// Only keys present in the OS environment but absent from the file are marked "env". +func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { + data, err := os.ReadFile(s.cfg.WebEnvPath) + var fileValues map[string]string + if err == nil { + fileValues = parseEnvText(string(data)) + } else { + fileValues = parseEnvText(string(web.SampleEnv)) + } + + values := make(map[string]string, len(allConfigKeys)) + sources := make(map[string]string, len(allConfigKeys)) + for _, key := range allConfigKeys { + if v, ok := fileValues[key]; ok && v != "" { + values[key] = v + sources[key] = "file" + } else if v, ok := os.LookupEnv(key); ok && v != "" { + values[key] = v + sources[key] = "env" + } + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(ConfigResponse{Values: values, Sources: sources}); err != nil { + slog.Error("failed encoding config to http", "msg", err.Error()) + } +} + +// handleGetConfigRaw returns the raw .env file contents as plain text. +func (s *Server) handleGetConfigRaw(w http.ResponseWriter, r *http.Request) { + data, err := os.ReadFile(s.cfg.WebEnvPath) + if err != nil { + data = web.SampleEnv + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if _, err := w.Write(data); err != nil { + slog.Error("failed writing http response", "msg", err.Error()) + } +} + +// handleSaveConfig writes the posted plain-text body directly to the .env file. +func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := os.WriteFile(s.cfg.WebEnvPath, data, 0600); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleResetConfig resets all settings and restarts the container. +func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) { + if err := os.WriteFile(s.cfg.WebEnvPath, web.SampleEnv, 0600); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + go func() { + time.Sleep(300 * time.Millisecond) + if err := syscall.Kill(1, syscall.SIGTERM); err != nil { + slog.Warn("failed to kill process", "msg", err.Error()) + } + + }() +} + +// handleSaveSchedule updates a single playlist's schedule in the .env file. +func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Day int `json:"day"` // 0=Sun…6=Sat, -1=every day + Hour int `json:"hour"` + Minute int `json:"minute"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + var envPrefix string + var defaultFlags string + + if def, ok := playlistDefs[body.Name]; ok { + envPrefix = def.EnvPrefix + defaultFlags = def.DefaultFlags + } else if customIDRe.MatchString(body.Name) { + envPrefix = customEnvPrefix(body.Name) + defaultFlags = "--playlist " + body.Name + } else { + http.Error(w, "unknown playlist name", http.StatusBadRequest) + return + } + + updates := map[string]string{} + if !body.Enabled { + // Toggle off — truly disable, regardless of day value carried over from state + updates[envPrefix+"_SCHEDULE"] = "" + updates[envPrefix+"_FLAGS"] = "" + } else if body.Day == -2 { + // "Never" — keep playlist active for manual runs but remove auto-schedule + updates[envPrefix+"_SCHEDULE"] = "" + updates[envPrefix+"_FLAGS"] = defaultFlags + } else { + dom := "*" + dow := "*" + if body.Day == 100 { + dom = "1" + } else if body.Day >= 0 { + dow = fmt.Sprintf("%d", body.Day) + } + updates[envPrefix+"_SCHEDULE"] = fmt.Sprintf("%d %d %s * %s", body.Minute, body.Hour, dom, dow) + updates[envPrefix+"_FLAGS"] = defaultFlags + } + + if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// updateEnvKeys reads the env file (falling back to fallback if missing), updates the +// given key=value pairs in-place preserving comments, and writes the result back. +func updateEnvKeys(path string, updates map[string]string, fallback []byte) error { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + data = fallback + } else if err != nil { + return err + } + + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + touched := make(map[string]bool) + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + key, _, ok := strings.Cut(trimmed, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if val, ok := updates[key]; ok { + if val == "" { + lines[i] = "" // remove by blanking + } else { + lines[i] = key + "=" + formatEnvValue(val) + } + touched[key] = true + } + } + + // 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)) + } + } + + // Filter out consecutive blank lines left by removals + out := make([]string, 0, len(lines)) + prevBlank := false + for _, l := range lines { + blank := strings.TrimSpace(l) == "" + if blank && prevBlank { + continue + } + out = append(out, l) + prevBlank = blank + } + + return os.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0600) +} + +// Check for special chars in env vars that might need quoting +func formatEnvValue(v string) string { + // preserve already quoted values + if strings.HasPrefix(v, "'") && strings.HasSuffix(v, "'") { + return v + } + + if strings.ContainsAny(v, `"$#?' `) { + // escape single quotes inside value + v = strings.ReplaceAll(v, `'`, `'\''`) + return fmt.Sprintf(`'%s'`, v) + } + + return v +} + +// ── Wizard ───────────────────────────────────────────────────────────────── + +// handleWizardStep1 saves discovery settings (username + enabled playlists with default schedules). +func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) { + var body struct { + User string `json:"user"` + Playlists []string `json:"playlists"` + DiscoveryMode string `json:"discovery_mode"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if body.User == "" { + http.Error(w, "user is required", http.StatusBadRequest) + return + } + + enabled := make(map[string]bool, len(body.Playlists)) + for _, p := range body.Playlists { + enabled[p] = true + } + + updates := map[string]string{ + "LISTENBRAINZ_USER": body.User, + "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode, + } + for name, def := range playlistDefs { + if enabled[name] { + updates[def.EnvPrefix+"_SCHEDULE"] = def.DefaultSchedule + updates[def.EnvPrefix+"_FLAGS"] = def.DefaultFlags + } else { + updates[def.EnvPrefix+"_SCHEDULE"] = "" + updates[def.EnvPrefix+"_FLAGS"] = "" + } + } + + if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleWizardStep2 saves media system configuration. +func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) { + var body struct { + System string `json:"system"` + URL string `json:"url"` + APIKey string `json:"api_key"` + LibraryName string `json:"library_name"` + Username string `json:"username"` + Password string `json:"password"` + PlaylistDir string `json:"playlist_dir"` + Sleep string `json:"sleep"` + PublicPlaylist bool `json:"public_playlist"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if body.System == "" { + http.Error(w, "system is required", http.StatusBadRequest) + return + } + + publicPlaylist := "" + if body.PublicPlaylist { + publicPlaylist = "true" + } + updates := map[string]string{ + "EXPLO_SYSTEM": body.System, + "SYSTEM_URL": body.URL, + "API_KEY": body.APIKey, + "LIBRARY_NAME": body.LibraryName, + "SYSTEM_USERNAME": body.Username, + "SYSTEM_PASSWORD": body.Password, + "PLAYLIST_DIR": body.PlaylistDir, + "SLEEP": body.Sleep, + "PUBLIC_PLAYLIST": publicPlaylist, + } + + if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleWizardStep3 saves downloader configuration. +func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { + var body struct { + DownloadDir string `json:"download_dir"` + UseSubdirectory bool `json:"use_subdirectory"` + MigrateDownloads bool `json:"migrate_downloads"` + DownloadServices []string `json:"download_services"` + YoutubeAPIKey string `json:"youtube_api_key"` + TrackExtension string `json:"track_extension"` // yt-dlp + FilterList string `json:"filter_list"` + SlskdURL string `json:"slskd_url"` + SlskdAPIKey string `json:"slskd_api_key"` + 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) + return + } + if len(body.DownloadServices) == 0 { + http.Error(w, "at least one download service is required", http.StatusBadRequest) + return + } + joined := strings.Join(body.DownloadServices, ",") + + useSubdir := "false" + if body.UseSubdirectory { + useSubdir = "true" + } + migrateDL := "false" + if body.MigrateDownloads { + migrateDL = "true" + } + updates := map[string]string{ + "DOWNLOAD_DIR": body.DownloadDir, + "USE_SUBDIRECTORY": useSubdir, + "MIGRATE_DOWNLOADS": migrateDL, + "DOWNLOAD_SERVICES": joined, + "YOUTUBE_API_KEY": body.YoutubeAPIKey, + "TRACK_EXTENSION": body.TrackExtension, // yt-dlp + "FILTER_LIST": body.FilterList, + "SLSKD_URL": body.SlskdURL, + "SLSKD_API_KEY": body.SlskdAPIKey, + "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 { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleBrowse returns subdirectories of the requested path for filesystem autocomplete. +func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { + path := filepath.Clean(r.URL.Query().Get("path")) + if path == "" || path == "." { + path = "/" + } + if !filepath.IsAbs(path) { + http.Error(w, "path must be absolute", http.StatusBadRequest) + return + } + + entries, err := os.ReadDir(path) + if err != nil { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode([]string{}); err != nil { + slog.Error("failed to encode empty slice", "msg", err.Error()) + } + return + } + + dirs := make([]string, 0) + for _, e := range entries { + if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { + dirs = append(dirs, filepath.Join(path, e.Name())) + } + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(dirs); err != nil { + slog.Warn("failed to encode directories to response", "err", err.Error()) + } +} + +// ── Manual run ───────────────────────────────────────────────────────────── + +var errRunAlreadyStarted = errors.New("run already in progress") + +// handleRun starts an explo run in the background. Clients follow output via /api/ui/run/events. +func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { + http.Error(w, "bad form data", http.StatusBadRequest) + return + } + + args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), + r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", + s.cfg.WebEnvPath) + + if err := s.startRun(args); err != nil { + if errors.Is(err, errRunAlreadyStarted) { + http.Error(w, "a run is already in progress", http.StatusConflict) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + if err := json.NewEncoder(w).Encode(s.currentRunStatus()); err != nil { + slog.Warn("failed to encode current run status", "msg", err.Error()) + } +} + +// triggerLibraryRefresh spawns the CLI with --refresh-only in the background to +// nudge the configured media server's library scan. Fire-and-forget: errors are +// logged but do not block the caller. +func (s *Server) triggerLibraryRefresh() { + go func() { + cmd := exec.Command(s.cfg.ExploPath, "--refresh-only", "--config", s.cfg.WebEnvPath) + env := make([]string, 0, len(os.Environ())) + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "WEB_UI=") { + env = append(env, e) + } + } + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + slog.Warn("library refresh failed", "err", err.Error(), "output", string(out)) + return + } + slog.Info("library refresh complete") + }() +} + +func (s *Server) startRun(args []string) error { + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, s.cfg.ExploPath, args...) + // Strip WEB_UI from env so the child process runs normally, not as web server. + env := make([]string, 0, len(os.Environ())) + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "WEB_UI=") { + env = append(env, e) + } + } + cmd.Env = env + + pr, pw, err := os.Pipe() + if err != nil { + cancel() + return fmt.Errorf("failed to create pipe: %w", err) + } + cmd.Stdout = pw + cmd.Stderr = pw + + lf, err := s.openRunLog() + if err != nil { + slog.Warn("failed to open run log", "err", err.Error()) + } + + s.manualRun.mu.Lock() + if s.manualRun.running { + s.manualRun.mu.Unlock() + cancel() + if err := pr.Close(); err != nil { + slog.Warn("failed to close file reader", "err", err.Error()) + } + + if err := pw.Close(); err != nil { + slog.Warn("failed to close file writer", "err", err.Error()) + } + if lf != nil { + if err := pw.Close(); err != nil { + slog.Warn("failed to close file writer", "err", err.Error()) + } + } + return errRunAlreadyStarted + } + s.manualRun.running = true + s.manualRun.cancel = cancel + s.manualRun.exitCode = nil + s.manualRun.logs = nil + s.manualRun.mu.Unlock() + + if err := cmd.Start(); err != nil { + s.finishRun(1) + cancel() + if err := pr.Close(); err != nil { + slog.Warn("failed to close file reader", "err", err.Error()) + } + + if err := pw.Close(); err != nil { + slog.Warn("failed to close file writer", "err", err.Error()) + } + if lf != nil { + if err := lf.Close(); err != nil { + slog.Warn("failed to close run log", "err", err.Error()) + } + } + return fmt.Errorf("failed to start explo: %w", err) + } + + // Close write end in parent so reader gets EOF when child exits. + if err := pw.Close(); err != nil { + slog.Warn("failed to close file writer", "err", err.Error()) + } + + go s.collectRunOutput(cmd, pr, lf) + return nil +} + +func (s *Server) collectRunOutput(cmd *exec.Cmd, pr *os.File, lf *os.File) { + defer func() { + if cerr := pr.Close(); cerr != nil { + slog.Error("failed to close source file", "err", cerr.Error()) + } + }() + + if lf != nil { + defer func() { + if cerr := lf.Close(); cerr != nil { + slog.Error("failed to close source file", "err", cerr.Error()) + } + }() + } + + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + line := scanner.Text() + if lf != nil { + if _, err := fmt.Fprintln(lf, line); err != nil { + s.appendRunLog("failed to write run output: " + err.Error()) + } + } + s.appendRunLog(line) + } + if err := scanner.Err(); err != nil { + s.appendRunLog("failed to read run output: " + err.Error()) + } + + code := 0 + if err := cmd.Wait(); err != nil && cmd.ProcessState == nil { + code = 1 + } + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } + s.finishRun(code) +} + +func (s *Server) handleStopRun(w http.ResponseWriter, r *http.Request) { + s.manualRun.mu.Lock() + cancel := s.manualRun.cancel + running := s.manualRun.running + s.manualRun.mu.Unlock() + + if !running || cancel == nil { + http.Error(w, "no run is currently in progress", http.StatusConflict) + return + } + + cancel() + w.WriteHeader(http.StatusAccepted) +} + +func (s *Server) currentRunStatus() RunStatus { + s.manualRun.mu.Lock() + defer s.manualRun.mu.Unlock() + + var exitCode *int + if s.manualRun.exitCode != nil { + code := *s.manualRun.exitCode + exitCode = &code + } + return RunStatus{Running: s.manualRun.running, ExitCode: exitCode} +} + +func (s *Server) handleRunStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(s.currentRunStatus()); err != nil { + slog.Warn("failed encoding current run status to response") + } +} + +// ── SSE event stream ─────────────────────────────────────────────────────── + +func (s *Server) appendRunLog(line string) { + event := runEvent{data: line} + + s.manualRun.mu.Lock() + s.manualRun.logs = append(s.manualRun.logs, line) + subscribers := make([]chan runEvent, 0, len(s.manualRun.subscribers)) + for ch := range s.manualRun.subscribers { + subscribers = append(subscribers, ch) + } + s.manualRun.mu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- event: + default: + } + } +} + +func (s *Server) finishRun(code int) { + done := runEvent{typ: "done", data: fmt.Sprintf("%d", code)} + + s.manualRun.mu.Lock() + s.manualRun.running = false + s.manualRun.cancel = nil + s.manualRun.exitCode = &code + subscribers := make([]chan runEvent, 0, len(s.manualRun.subscribers)) + for ch := range s.manualRun.subscribers { + subscribers = append(subscribers, ch) + delete(s.manualRun.subscribers, ch) + } + s.manualRun.mu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- done: + default: + } + close(ch) + } +} + +// handleRunEvents streams the current in-memory run log, then follows new lines +// until the active run exits. Safe to reconnect after a browser refresh. +func (s *Server) handleRunEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + sendEvent := func(typ, data string) { + if typ != "" { + if _, err := fmt.Fprintf(w, "event: %s\n", typ); err != nil { + slog.Warn("failed handling run event", "err", err.Error()) + } + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + slog.Warn("failed handling run event", "err", err.Error()) + } + flusher.Flush() + } + + ch := make(chan runEvent, 256) + s.manualRun.mu.Lock() + lines := append([]string(nil), s.manualRun.logs...) + running := s.manualRun.running + var exitCode *int + if s.manualRun.exitCode != nil { + code := *s.manualRun.exitCode + exitCode = &code + } + if running { + s.manualRun.subscribers[ch] = struct{}{} + } + s.manualRun.mu.Unlock() + + for _, line := range lines { + sendEvent("", line) + } + if !running { + if exitCode != nil { + sendEvent("done", fmt.Sprintf("%d", *exitCode)) + } + return + } + + defer s.unsubscribeRun(ch) + for { + select { + case <-r.Context().Done(): + return + case ev, ok := <-ch: + if !ok { + return + } + sendEvent(ev.typ, ev.data) + if ev.typ == "done" { + return + } + } + } +} + +func (s *Server) unsubscribeRun(ch chan runEvent) { + s.manualRun.mu.Lock() + delete(s.manualRun.subscribers, ch) + s.manualRun.mu.Unlock() +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string { + args := []string{"--config", WebEnvPath} + if playlist != "" { + args = append(args, "--playlist", playlist) + } + if downloadMode != "" { + args = append(args, "--download-mode", downloadMode) + } + if noPersist { + args = append(args, "--persist=false") + } + if excludeLocal { + args = append(args, "--exclude-local") + } + return args +} +>>>>>>> f3c42ac (Update gui to include lidarr) 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 */} +
+ 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" + /> +
+
+
+
+
@@ -658,12 +725,15 @@ export default function Wizard({ 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 || "", adminAuthMethod: config.ADMIN_AUTH_METHOD || "password", adminApiKey: config.ADMIN_API_KEY || "", @@ -672,6 +742,7 @@ export default function Wizard({ }; }); + const setField = (key, val) => setFields((prev) => ({ ...prev, [key]: val })); const lockedKeys = Object.entries(envSources) @@ -749,6 +820,8 @@ export default function Wizard({ 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 0d90b879..49993e88 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 68511ba1..ac611196 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -103,6 +103,22 @@ 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 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 c5a9384cb618daf38ff1674b18838d0d46658cd5 Mon Sep 17 00:00:00 2001 From: Avery Date: Fri, 5 Jun 2026 14:27:43 -0400 Subject: [PATCH 06/39] Don't add an album that exists Remove debugs Set trackfile after download starts Fix rebase Remove extraneous code Restore untouched file --- go.sum | 2 - src/downloader/lidarr.go | 73 +-- src/web/backend/server.go | 944 +------------------------------------- 3 files changed, 43 insertions(+), 976 deletions(-) diff --git a/go.sum b/go.sum index 2e566e94..7a155d3e 100644 --- a/go.sum +++ b/go.sum @@ -85,7 +85,6 @@ 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= @@ -122,7 +121,6 @@ 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= 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 } diff --git a/src/web/backend/server.go b/src/web/backend/server.go index a61c5a02..9da7747e 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -31,18 +31,11 @@ type Server struct { cfg config.ServerConfig mux *http.ServeMux server *http.Server -<<<<<<< HEAD authStore *auth.AuthStore settings *settings.Settings cronJobs *jobs.Jobs manualRun *run.ManualRun customPlaylist *playlist.Playlist -======= - authStore *AuthStore - cronJobs *Jobs - sessionManager *SessionManager - manualRun manualRunState ->>>>>>> f3c42ac (Update gui to include lidarr) } func NewServer(cfg config.ServerConfig) *Server { @@ -53,23 +46,16 @@ func NewServer(cfg config.ServerConfig) *Server { "session", ) -<<<<<<< HEAD authStore := auth.NewAuthStore( -======= - authStore := NewAuthStore( ->>>>>>> f3c42ac (Update gui to include lidarr) cfg.Username, cfg.Password, sessionManager, ) -<<<<<<< HEAD webCfg := app.Config{ WebEnvPath: cfg.WebEnvPath, WebDataDir: cfg.WebDataDir, ExploPath: cfg.ExploPath, } -======= ->>>>>>> f3c42ac (Update gui to include lidarr) settings := settings.NewSettings(webCfg) @@ -86,16 +72,10 @@ func NewServer(cfg config.ServerConfig) *Server { Handler: sessionManager.Handle(mux), }, authStore: authStore, -<<<<<<< HEAD settings: settings, cronJobs: cronJobs, manualRun: manualRun, customPlaylist: playlist, -======= - cronJobs: cronJobs, - sessionManager: sessionManager, - manualRun: newManualRunState(), ->>>>>>> f3c42ac (Update gui to include lidarr) } s.registerRoutes() @@ -179,19 +159,6 @@ func (s *Server) startJobs() { s.cronJobs.Start() } -<<<<<<< HEAD -======= -func (s *Server) PrefetchCovers() { - - coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") - - url := randomLocalCoverHiRes(coversDir) - if url == "" { - fetchSitewideCovers(coversDir) - } -} - ->>>>>>> f3c42ac (Update gui to include lidarr) // spaFS returns the filesystem to serve the frontend from. // When WEB_DEV=true, serves directly from src/web/dist on disk so that // running "npm run build" reflects changes without recompiling the binary. @@ -204,913 +171,4 @@ func spaFS() (fs.FS, []byte) { embedded, _ := fs.Sub(web.DistFiles, "dist") index, _ := fs.ReadFile(embedded, "index.html") return embedded, index -<<<<<<< HEAD -} -======= -} - -func (s *Server) registerRoutes() { - distFS, indexHTML := spaFS() - fileServer := http.FileServer(http.FS(distFS)) - - // SPA fallback: serve static assets when they exist, otherwise serve index.html. - s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/") - if path != "" { - if _, err := fs.Stat(distFS, path); err == nil { - fileServer.ServeHTTP(w, r) - return - } - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if _, err := w.Write(indexHTML); err != nil { - slog.Error("failed writing to http", "msg", err.Error()) - } - }) - s.mux.Handle("GET /api/ui/config", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetConfig))) - s.mux.Handle("GET /api/ui/config/raw", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetConfigRaw))) - s.mux.Handle("POST /api/ui/config", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveConfig))) - s.mux.Handle("POST /api/ui/config/reset", s.authStore.RequireAuth(http.HandlerFunc(s.handleResetConfig))) - s.mux.Handle("POST /api/ui/config/schedules", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveSchedule))) - s.mux.Handle("POST /api/ui/wizard/step1", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep1))) - s.mux.Handle("POST /api/ui/wizard/step2", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep2))) - s.mux.Handle("POST /api/ui/wizard/step3", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep3))) - s.mux.Handle("GET /api/ui/browse", s.authStore.RequireAuth(http.HandlerFunc(s.handleBrowse))) - s.mux.Handle("POST /api/ui/run", s.authStore.RequireAuth(http.HandlerFunc(s.handleRun))) - s.mux.Handle("GET /api/ui/run/events", s.authStore.RequireAuth(http.HandlerFunc(s.handleRunEvents))) - s.mux.Handle("POST /api/ui/run/stop", s.authStore.RequireAuth(http.HandlerFunc(s.handleStopRun))) - s.mux.Handle("GET /api/ui/run/status", s.authStore.RequireAuth(http.HandlerFunc(s.handleRunStatus))) - s.mux.Handle("GET /api/ui/logs", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetLog))) - s.mux.Handle("GET /api/ui/playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetPlaylist))) - s.mux.Handle("POST /api/ui/playlists/prefetch", s.authStore.RequireAuth(http.HandlerFunc(s.handlePrefetchCovers))) - s.mux.Handle("GET /api/ui/custom-playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetCustomPlaylists))) - s.mux.Handle("POST /api/ui/custom-playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleImportCustomPlaylist))) - s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.authStore.RequireAuth(http.HandlerFunc(s.handleDeleteCustomPlaylist))) - s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.authStore.RequireAuth(http.HandlerFunc(s.handleRefreshCustomPlaylist))) - s.mux.Handle("POST /api/ui/logout", s.authStore.RequireAuth(http.HandlerFunc(s.handleLogout))) - s.mux.HandleFunc("GET /api/ui/csrf", s.csrfHandler) - s.mux.HandleFunc("POST /api/ui/login", s.handleLogin) - s.mux.HandleFunc("GET /api/ui/auth/status", s.handleAuthStatus) - s.mux.HandleFunc("GET /api/ui/background-art", s.handleBackgroundArt) - s.mux.HandleFunc("GET /api/ui/setup-status", s.handleSetupStatus) - - coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") - s.mux.Handle("/api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) -} - -// ── Logging ──────────────────────────────────────────────────────────────── - -// logPath returns the path to the single rolling log file. -func (s *Server) logPath() string { - return filepath.Join(s.cfg.WebDataDir, "logs", "explo.log") -} - -// initServerLog redirects the default slog handler so all server log output -// goes to both stderr and the rolling log file. -func (s *Server) initServerLog() { - lf, err := s.openRunLog() - if err != nil { - return - } - w := io.MultiWriter(os.Stderr, lf) - slog.SetDefault(slog.New(slog.NewTextHandler(w, nil))) -} - -// openRunLog opens the single rolling log file in append mode. -func (s *Server) openRunLog() (*os.File, error) { - p := s.logPath() - if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { - return nil, err - } - return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) -} - -// handleSetupStatus returns {"wizard_complete": bool} for first time setups. Public — no auth required. -func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { - wizardComplete := false - if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { - wizardComplete = parseEnvText(string(data))["WIZARD_COMPLETE"] == "true" - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]bool{"wizard_complete": wizardComplete}); err != nil { - slog.Error("failed encoding setup status", "err", err.Error()) - } -} - -func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { - sess := s.sessionManager.GetSession(r) - auth, _ := sess.Get("authenticated").(bool) - if !auth { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - w.WriteHeader(http.StatusOK) -} - -func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - err := http.StatusMethodNotAllowed - http.Error(w, "Invalid request method", err) - return - } - - if err := r.ParseForm(); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - - username := r.FormValue("username") - password := r.FormValue("password") - - if !s.authStore.CompareCreds(username, password) { - http.Error(w, "invalid credentials", http.StatusUnauthorized) - return - } - sess := s.sessionManager.GetSession(r) - sess.Put("authenticated", true) - sess.Put("username", username) - //s.sessionManager.Migrate(sess) - slog.Info("successful login", "user", username) -} - -func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - sess := s.sessionManager.GetSession(r) - sess.Delete("authenticated") - sess.Delete("username") - w.WriteHeader(http.StatusOK) -} - -// handleGetLog returns the contents of the rolling log file. -func (s *Server) handleGetLog(w http.ResponseWriter, r *http.Request) { - data, err := os.ReadFile(s.logPath()) - if err != nil && !os.IsNotExist(err) { - http.Error(w, "failed to read log", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - if _, err := w.Write(data); err != nil { - slog.Error("failed writing http response", "msg", err.Error()) - } -} - -func (s *Server) csrfHandler(w http.ResponseWriter, r *http.Request) { - session := s.sessionManager.GetSession(r) - - token, _ := session.Get("csrf_token").(string) - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(map[string]string{ - "csrf_token": token, - }); err != nil { - slog.Error("failed encoding token to http", "msg", err.Error()) - } -} - -// ── Config ───────────────────────────────────────────────────────────────── - -// parseEnvText parses key=value lines, ignoring comments, blanks and unquotes variables -// . -func parseEnvText(text string) map[string]string { - out := map[string]string{} - for line := range strings.SplitSeq(text, "\n") { - t := strings.TrimSpace(line) - if t == "" || strings.HasPrefix(t, "#") { - continue - } - k, v, ok := strings.Cut(t, "=") - if !ok { - continue - } - if k = strings.TrimSpace(k); k != "" { - v = strings.TrimSpace(v) - - // unquote if quoted - if len(v) >= 2 { - if (v[0] == '\'' && v[len(v)-1] == '\'') || - (v[0] == '"' && v[len(v)-1] == '"') { - v = v[1 : len(v)-1] - } - } - out[k] = v - } - } - return out -} - -// handleGetConfig returns resolved config as JSON: { values, sources }. -// File keys are checked first because cleanenv sets them as OS env vars on startup, -// so checking os.LookupEnv first would misclassify all file keys as "env". -// Only keys present in the OS environment but absent from the file are marked "env". -func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { - data, err := os.ReadFile(s.cfg.WebEnvPath) - var fileValues map[string]string - if err == nil { - fileValues = parseEnvText(string(data)) - } else { - fileValues = parseEnvText(string(web.SampleEnv)) - } - - values := make(map[string]string, len(allConfigKeys)) - sources := make(map[string]string, len(allConfigKeys)) - for _, key := range allConfigKeys { - if v, ok := fileValues[key]; ok && v != "" { - values[key] = v - sources[key] = "file" - } else if v, ok := os.LookupEnv(key); ok && v != "" { - values[key] = v - sources[key] = "env" - } - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(ConfigResponse{Values: values, Sources: sources}); err != nil { - slog.Error("failed encoding config to http", "msg", err.Error()) - } -} - -// handleGetConfigRaw returns the raw .env file contents as plain text. -func (s *Server) handleGetConfigRaw(w http.ResponseWriter, r *http.Request) { - data, err := os.ReadFile(s.cfg.WebEnvPath) - if err != nil { - data = web.SampleEnv - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - if _, err := w.Write(data); err != nil { - slog.Error("failed writing http response", "msg", err.Error()) - } -} - -// handleSaveConfig writes the posted plain-text body directly to the .env file. -func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) { - data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := os.WriteFile(s.cfg.WebEnvPath, data, 0600); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} - -// handleResetConfig resets all settings and restarts the container. -func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) { - if err := os.WriteFile(s.cfg.WebEnvPath, web.SampleEnv, 0600); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - go func() { - time.Sleep(300 * time.Millisecond) - if err := syscall.Kill(1, syscall.SIGTERM); err != nil { - slog.Warn("failed to kill process", "msg", err.Error()) - } - - }() -} - -// handleSaveSchedule updates a single playlist's schedule in the .env file. -func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - Day int `json:"day"` // 0=Sun…6=Sat, -1=every day - Hour int `json:"hour"` - Minute int `json:"minute"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - - var envPrefix string - var defaultFlags string - - if def, ok := playlistDefs[body.Name]; ok { - envPrefix = def.EnvPrefix - defaultFlags = def.DefaultFlags - } else if customIDRe.MatchString(body.Name) { - envPrefix = customEnvPrefix(body.Name) - defaultFlags = "--playlist " + body.Name - } else { - http.Error(w, "unknown playlist name", http.StatusBadRequest) - return - } - - updates := map[string]string{} - if !body.Enabled { - // Toggle off — truly disable, regardless of day value carried over from state - updates[envPrefix+"_SCHEDULE"] = "" - updates[envPrefix+"_FLAGS"] = "" - } else if body.Day == -2 { - // "Never" — keep playlist active for manual runs but remove auto-schedule - updates[envPrefix+"_SCHEDULE"] = "" - updates[envPrefix+"_FLAGS"] = defaultFlags - } else { - dom := "*" - dow := "*" - if body.Day == 100 { - dom = "1" - } else if body.Day >= 0 { - dow = fmt.Sprintf("%d", body.Day) - } - updates[envPrefix+"_SCHEDULE"] = fmt.Sprintf("%d %d %s * %s", body.Minute, body.Hour, dom, dow) - updates[envPrefix+"_FLAGS"] = defaultFlags - } - - if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} - -// updateEnvKeys reads the env file (falling back to fallback if missing), updates the -// given key=value pairs in-place preserving comments, and writes the result back. -func updateEnvKeys(path string, updates map[string]string, fallback []byte) error { - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - data = fallback - } else if err != nil { - return err - } - - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - touched := make(map[string]bool) - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - key, _, ok := strings.Cut(trimmed, "=") - if !ok { - continue - } - key = strings.TrimSpace(key) - if val, ok := updates[key]; ok { - if val == "" { - lines[i] = "" // remove by blanking - } else { - lines[i] = key + "=" + formatEnvValue(val) - } - touched[key] = true - } - } - - // 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)) - } - } - - // Filter out consecutive blank lines left by removals - out := make([]string, 0, len(lines)) - prevBlank := false - for _, l := range lines { - blank := strings.TrimSpace(l) == "" - if blank && prevBlank { - continue - } - out = append(out, l) - prevBlank = blank - } - - return os.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0600) -} - -// Check for special chars in env vars that might need quoting -func formatEnvValue(v string) string { - // preserve already quoted values - if strings.HasPrefix(v, "'") && strings.HasSuffix(v, "'") { - return v - } - - if strings.ContainsAny(v, `"$#?' `) { - // escape single quotes inside value - v = strings.ReplaceAll(v, `'`, `'\''`) - return fmt.Sprintf(`'%s'`, v) - } - - return v -} - -// ── Wizard ───────────────────────────────────────────────────────────────── - -// handleWizardStep1 saves discovery settings (username + enabled playlists with default schedules). -func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) { - var body struct { - User string `json:"user"` - Playlists []string `json:"playlists"` - DiscoveryMode string `json:"discovery_mode"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - if body.User == "" { - http.Error(w, "user is required", http.StatusBadRequest) - return - } - - enabled := make(map[string]bool, len(body.Playlists)) - for _, p := range body.Playlists { - enabled[p] = true - } - - updates := map[string]string{ - "LISTENBRAINZ_USER": body.User, - "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode, - } - for name, def := range playlistDefs { - if enabled[name] { - updates[def.EnvPrefix+"_SCHEDULE"] = def.DefaultSchedule - updates[def.EnvPrefix+"_FLAGS"] = def.DefaultFlags - } else { - updates[def.EnvPrefix+"_SCHEDULE"] = "" - updates[def.EnvPrefix+"_FLAGS"] = "" - } - } - - if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} - -// handleWizardStep2 saves media system configuration. -func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) { - var body struct { - System string `json:"system"` - URL string `json:"url"` - APIKey string `json:"api_key"` - LibraryName string `json:"library_name"` - Username string `json:"username"` - Password string `json:"password"` - PlaylistDir string `json:"playlist_dir"` - Sleep string `json:"sleep"` - PublicPlaylist bool `json:"public_playlist"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - if body.System == "" { - http.Error(w, "system is required", http.StatusBadRequest) - return - } - - publicPlaylist := "" - if body.PublicPlaylist { - publicPlaylist = "true" - } - updates := map[string]string{ - "EXPLO_SYSTEM": body.System, - "SYSTEM_URL": body.URL, - "API_KEY": body.APIKey, - "LIBRARY_NAME": body.LibraryName, - "SYSTEM_USERNAME": body.Username, - "SYSTEM_PASSWORD": body.Password, - "PLAYLIST_DIR": body.PlaylistDir, - "SLEEP": body.Sleep, - "PUBLIC_PLAYLIST": publicPlaylist, - } - - if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} - -// handleWizardStep3 saves downloader configuration. -func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { - var body struct { - DownloadDir string `json:"download_dir"` - UseSubdirectory bool `json:"use_subdirectory"` - MigrateDownloads bool `json:"migrate_downloads"` - DownloadServices []string `json:"download_services"` - YoutubeAPIKey string `json:"youtube_api_key"` - TrackExtension string `json:"track_extension"` // yt-dlp - FilterList string `json:"filter_list"` - SlskdURL string `json:"slskd_url"` - SlskdAPIKey string `json:"slskd_api_key"` - 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) - return - } - if len(body.DownloadServices) == 0 { - http.Error(w, "at least one download service is required", http.StatusBadRequest) - return - } - joined := strings.Join(body.DownloadServices, ",") - - useSubdir := "false" - if body.UseSubdirectory { - useSubdir = "true" - } - migrateDL := "false" - if body.MigrateDownloads { - migrateDL = "true" - } - updates := map[string]string{ - "DOWNLOAD_DIR": body.DownloadDir, - "USE_SUBDIRECTORY": useSubdir, - "MIGRATE_DOWNLOADS": migrateDL, - "DOWNLOAD_SERVICES": joined, - "YOUTUBE_API_KEY": body.YoutubeAPIKey, - "TRACK_EXTENSION": body.TrackExtension, // yt-dlp - "FILTER_LIST": body.FilterList, - "SLSKD_URL": body.SlskdURL, - "SLSKD_API_KEY": body.SlskdAPIKey, - "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 { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} - -// handleBrowse returns subdirectories of the requested path for filesystem autocomplete. -func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { - path := filepath.Clean(r.URL.Query().Get("path")) - if path == "" || path == "." { - path = "/" - } - if !filepath.IsAbs(path) { - http.Error(w, "path must be absolute", http.StatusBadRequest) - return - } - - entries, err := os.ReadDir(path) - if err != nil { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode([]string{}); err != nil { - slog.Error("failed to encode empty slice", "msg", err.Error()) - } - return - } - - dirs := make([]string, 0) - for _, e := range entries { - if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { - dirs = append(dirs, filepath.Join(path, e.Name())) - } - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(dirs); err != nil { - slog.Warn("failed to encode directories to response", "err", err.Error()) - } -} - -// ── Manual run ───────────────────────────────────────────────────────────── - -var errRunAlreadyStarted = errors.New("run already in progress") - -// handleRun starts an explo run in the background. Clients follow output via /api/ui/run/events. -func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { - if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { - http.Error(w, "bad form data", http.StatusBadRequest) - return - } - - args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), - r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", - s.cfg.WebEnvPath) - - if err := s.startRun(args); err != nil { - if errors.Is(err, errRunAlreadyStarted) { - http.Error(w, "a run is already in progress", http.StatusConflict) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - if err := json.NewEncoder(w).Encode(s.currentRunStatus()); err != nil { - slog.Warn("failed to encode current run status", "msg", err.Error()) - } -} - -// triggerLibraryRefresh spawns the CLI with --refresh-only in the background to -// nudge the configured media server's library scan. Fire-and-forget: errors are -// logged but do not block the caller. -func (s *Server) triggerLibraryRefresh() { - go func() { - cmd := exec.Command(s.cfg.ExploPath, "--refresh-only", "--config", s.cfg.WebEnvPath) - env := make([]string, 0, len(os.Environ())) - for _, e := range os.Environ() { - if !strings.HasPrefix(e, "WEB_UI=") { - env = append(env, e) - } - } - cmd.Env = env - out, err := cmd.CombinedOutput() - if err != nil { - slog.Warn("library refresh failed", "err", err.Error(), "output", string(out)) - return - } - slog.Info("library refresh complete") - }() -} - -func (s *Server) startRun(args []string) error { - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, s.cfg.ExploPath, args...) - // Strip WEB_UI from env so the child process runs normally, not as web server. - env := make([]string, 0, len(os.Environ())) - for _, e := range os.Environ() { - if !strings.HasPrefix(e, "WEB_UI=") { - env = append(env, e) - } - } - cmd.Env = env - - pr, pw, err := os.Pipe() - if err != nil { - cancel() - return fmt.Errorf("failed to create pipe: %w", err) - } - cmd.Stdout = pw - cmd.Stderr = pw - - lf, err := s.openRunLog() - if err != nil { - slog.Warn("failed to open run log", "err", err.Error()) - } - - s.manualRun.mu.Lock() - if s.manualRun.running { - s.manualRun.mu.Unlock() - cancel() - if err := pr.Close(); err != nil { - slog.Warn("failed to close file reader", "err", err.Error()) - } - - if err := pw.Close(); err != nil { - slog.Warn("failed to close file writer", "err", err.Error()) - } - if lf != nil { - if err := pw.Close(); err != nil { - slog.Warn("failed to close file writer", "err", err.Error()) - } - } - return errRunAlreadyStarted - } - s.manualRun.running = true - s.manualRun.cancel = cancel - s.manualRun.exitCode = nil - s.manualRun.logs = nil - s.manualRun.mu.Unlock() - - if err := cmd.Start(); err != nil { - s.finishRun(1) - cancel() - if err := pr.Close(); err != nil { - slog.Warn("failed to close file reader", "err", err.Error()) - } - - if err := pw.Close(); err != nil { - slog.Warn("failed to close file writer", "err", err.Error()) - } - if lf != nil { - if err := lf.Close(); err != nil { - slog.Warn("failed to close run log", "err", err.Error()) - } - } - return fmt.Errorf("failed to start explo: %w", err) - } - - // Close write end in parent so reader gets EOF when child exits. - if err := pw.Close(); err != nil { - slog.Warn("failed to close file writer", "err", err.Error()) - } - - go s.collectRunOutput(cmd, pr, lf) - return nil -} - -func (s *Server) collectRunOutput(cmd *exec.Cmd, pr *os.File, lf *os.File) { - defer func() { - if cerr := pr.Close(); cerr != nil { - slog.Error("failed to close source file", "err", cerr.Error()) - } - }() - - if lf != nil { - defer func() { - if cerr := lf.Close(); cerr != nil { - slog.Error("failed to close source file", "err", cerr.Error()) - } - }() - } - - scanner := bufio.NewScanner(pr) - for scanner.Scan() { - line := scanner.Text() - if lf != nil { - if _, err := fmt.Fprintln(lf, line); err != nil { - s.appendRunLog("failed to write run output: " + err.Error()) - } - } - s.appendRunLog(line) - } - if err := scanner.Err(); err != nil { - s.appendRunLog("failed to read run output: " + err.Error()) - } - - code := 0 - if err := cmd.Wait(); err != nil && cmd.ProcessState == nil { - code = 1 - } - if cmd.ProcessState != nil { - code = cmd.ProcessState.ExitCode() - } - s.finishRun(code) -} - -func (s *Server) handleStopRun(w http.ResponseWriter, r *http.Request) { - s.manualRun.mu.Lock() - cancel := s.manualRun.cancel - running := s.manualRun.running - s.manualRun.mu.Unlock() - - if !running || cancel == nil { - http.Error(w, "no run is currently in progress", http.StatusConflict) - return - } - - cancel() - w.WriteHeader(http.StatusAccepted) -} - -func (s *Server) currentRunStatus() RunStatus { - s.manualRun.mu.Lock() - defer s.manualRun.mu.Unlock() - - var exitCode *int - if s.manualRun.exitCode != nil { - code := *s.manualRun.exitCode - exitCode = &code - } - return RunStatus{Running: s.manualRun.running, ExitCode: exitCode} -} - -func (s *Server) handleRunStatus(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(s.currentRunStatus()); err != nil { - slog.Warn("failed encoding current run status to response") - } -} - -// ── SSE event stream ─────────────────────────────────────────────────────── - -func (s *Server) appendRunLog(line string) { - event := runEvent{data: line} - - s.manualRun.mu.Lock() - s.manualRun.logs = append(s.manualRun.logs, line) - subscribers := make([]chan runEvent, 0, len(s.manualRun.subscribers)) - for ch := range s.manualRun.subscribers { - subscribers = append(subscribers, ch) - } - s.manualRun.mu.Unlock() - - for _, ch := range subscribers { - select { - case ch <- event: - default: - } - } -} - -func (s *Server) finishRun(code int) { - done := runEvent{typ: "done", data: fmt.Sprintf("%d", code)} - - s.manualRun.mu.Lock() - s.manualRun.running = false - s.manualRun.cancel = nil - s.manualRun.exitCode = &code - subscribers := make([]chan runEvent, 0, len(s.manualRun.subscribers)) - for ch := range s.manualRun.subscribers { - subscribers = append(subscribers, ch) - delete(s.manualRun.subscribers, ch) - } - s.manualRun.mu.Unlock() - - for _, ch := range subscribers { - select { - case ch <- done: - default: - } - close(ch) - } -} - -// handleRunEvents streams the current in-memory run log, then follows new lines -// until the active run exits. Safe to reconnect after a browser refresh. -func (s *Server) handleRunEvents(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming not supported", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - - sendEvent := func(typ, data string) { - if typ != "" { - if _, err := fmt.Fprintf(w, "event: %s\n", typ); err != nil { - slog.Warn("failed handling run event", "err", err.Error()) - } - } - if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { - slog.Warn("failed handling run event", "err", err.Error()) - } - flusher.Flush() - } - - ch := make(chan runEvent, 256) - s.manualRun.mu.Lock() - lines := append([]string(nil), s.manualRun.logs...) - running := s.manualRun.running - var exitCode *int - if s.manualRun.exitCode != nil { - code := *s.manualRun.exitCode - exitCode = &code - } - if running { - s.manualRun.subscribers[ch] = struct{}{} - } - s.manualRun.mu.Unlock() - - for _, line := range lines { - sendEvent("", line) - } - if !running { - if exitCode != nil { - sendEvent("done", fmt.Sprintf("%d", *exitCode)) - } - return - } - - defer s.unsubscribeRun(ch) - for { - select { - case <-r.Context().Done(): - return - case ev, ok := <-ch: - if !ok { - return - } - sendEvent(ev.typ, ev.data) - if ev.typ == "done" { - return - } - } - } -} - -func (s *Server) unsubscribeRun(ch chan runEvent) { - s.manualRun.mu.Lock() - delete(s.manualRun.subscribers, ch) - s.manualRun.mu.Unlock() -} - -// ── Helpers ──────────────────────────────────────────────────────────────── - -func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string { - args := []string{"--config", WebEnvPath} - if playlist != "" { - args = append(args, "--playlist", playlist) - } - if downloadMode != "" { - args = append(args, "--download-mode", downloadMode) - } - if noPersist { - args = append(args, "--persist=false") - } - if excludeLocal { - args = append(args, "--exclude-local") - } - return args -} ->>>>>>> f3c42ac (Update gui to include lidarr) +} \ No newline at end of file From 0237393c60d7fe64dbbf2c30768fdf4500b4d1b1 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 14:45:11 +0300 Subject: [PATCH 07/39] check trackID when monitoring; check Lidarr history for completed downloads --- src/downloader/lidarr.go | 107 +++++++++++++++++++++++++++++++++++--- src/downloader/monitor.go | 7 +-- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 08982725..6e47fb06 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -69,10 +69,40 @@ type LidarrQueueItem struct { EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"` Added time.Time `json:"added"` Status string `json:"status"` - ID int64 `json:"id"` + 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 { Path string `json:"path"` DefaultMetadataProfileId int `json:"defaultMetadataProfileId"` @@ -212,12 +242,13 @@ func (c Lidarr) GetTrack(track *models.Track) error { }, } - body, err := json.Marshal(payload) + 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) - _, err = c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(body), c.Headers) + + 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) @@ -225,7 +256,14 @@ func (c Lidarr) GetTrack(track *models.Track) error { } return fmt.Errorf("failed to add album: %w", err) } - track.File = track.Title + var album Album + + if err = util.ParseResp(body, &album); err != nil { + return fmt.Errorf("failed to unmarshal lidarr album: %w", err) + } + + track.ID = strconv.Itoa(album.ID) + track.File = track.CleanTitle slog.Info("download started") return nil } @@ -244,21 +282,76 @@ func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatu } statuses := make(map[string]FileStatus) + for _, track := range tracks { + if track.ID == "" || track.Present { + continue + } + + file, err := c.checkHistory(*track) + if err != nil { + slog.Warn("failed to check download history", "err", err) + } + + if file != "" { + fmt.Printf("lidarr downloaded: %s, %s\n",track.Album, file) + statuses[track.ID] = FileStatus{ + ID: track.ID, + Filename: file, + State: "Succeeded", + BytesTransferred: 1, + BytesRemaining: 0, + PercentComplete: 100, + QueueID: "", + } + } + + } for _, record := range queue.Records { - // MVP assumption: record.Title matches track.File closely enough - statuses[record.Title] = FileStatus{ - ID: strconv.FormatInt(record.ID, 10), + ID := strconv.Itoa(record.AlbumID) + 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 := strings.Contains(strings.ToLower(r.SourceTitle), strings.ToLower(track.CleanTitle)) || strings.Contains(strings.ToLower(r.Track.Title), strings.ToLower(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) diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 38a9d7d5..f12dd044 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 { @@ -70,7 +71,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++ @@ -97,7 +98,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 @@ -111,7 +112,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } else if monitoredTime > monCfg.MonitorDuration || fileStatus.State == "Errored" { slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "file", track.File, "state", fileStatus.State, "duration", monitoredTime,) 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 From 815751e361ff6eca44eb5e8e17c1f85b31abc96d Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 14:47:19 +0300 Subject: [PATCH 08/39] use queueID when deleting lidarr downloads --- src/downloader/lidarr.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 6e47fb06..ca029a4e 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -408,9 +408,6 @@ func percent(total, remaining int64) float64 { func (c Lidarr) deleteDownload(ID string) error { reqParams := fmt.Sprintf("/api/v1/queue/%s", ID) - 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) 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) @@ -418,8 +415,11 @@ func (c Lidarr) deleteDownload(ID string) error { return nil } -func (c *Lidarr) Cleanup(track models.Track, fileID string) error { - if err := c.deleteDownload(fileID); err != 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 From af711991ac2f397934066e7e227fcb7262851eb1 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 14:55:45 +0300 Subject: [PATCH 09/39] implement checking search status --- src/downloader/lidarr.go | 74 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index ca029a4e..c8095e94 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/url" + "slices" "strconv" "strings" "time" @@ -113,6 +114,39 @@ 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, @@ -218,6 +252,10 @@ func (c Lidarr) GetTrack(track *models.Track) error { if err != nil { return fmt.Errorf("failed to trigger album search: %w", err) } + + if err := c.searchStatus(albumID, 0); err != nil { + return fmt.Errorf("album search failed: %w", err) + } track.File = track.Title return nil } @@ -257,16 +295,50 @@ func (c Lidarr) GetTrack(track *models.Track) error { 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, 0); err != nil { + return fmt.Errorf("album search failed: %w", err) + } + track.ID = strconv.Itoa(album.ID) track.File = track.CleanTitle slog.Info("download started") 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(10 * time.Second) + return c.searchStatus(AlbumID, count+1) +} func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { req := "/api/v1/queue" From 628bf30eb4fa6e55f3f6cf482dbbc5dd887d75f0 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 15:01:27 +0300 Subject: [PATCH 10/39] change lidarr config values --- src/config/config.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 4e13862b..dc99703f 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -154,9 +154,8 @@ type SlskdMon struct { 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 + 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"` Filters Filters @@ -164,8 +163,8 @@ type Lidarr struct { } type LidarrMon struct { - Interval time.Duration `env:"LIDARR_MONITOR_INTERVAL" env-default:"1m"` - Duration time.Duration `env:"LIDARR_MONITOR_DURATION" env-default:"15m"` + Interval int `env:"LIDARR_MONITOR_INTERVAL" env-default:"1"` + Duration int `env:"LIDARR_MONITOR_DURATION" env-default:"20"` } type DiscoveryConfig struct { From 826301ee58ba6825596fab9d8563f395a25fe268 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 15:10:51 +0300 Subject: [PATCH 11/39] use helper; handle edge-cases --- src/downloader/lidarr.go | 4 ++-- src/downloader/monitor.go | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index c8095e94..4d1d9219 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -213,7 +213,7 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { } for _, t := range lidarrTracks { - if strings.Contains(strings.ToLower(t.Title), strings.ToLower(track.Title)) && t.HasFile { + if util.ContainsFold(t.Title, track.Title) && t.HasFile { track.Present = true slog.Info("track already present in Lidarr", "track", trackDetails) return nil @@ -415,7 +415,7 @@ func (c *Lidarr) checkHistory(track models.Track) (string, error) { for _, r := range history.Records { mbIDMatch := (mbTrack != "" && mbTrack == r.Track.ForeignTrackID) || (mbReleaseTrack != "" && mbReleaseTrack == r.Track.ForeignTrackID) - titleMatch := strings.Contains(strings.ToLower(r.SourceTitle), strings.ToLower(track.CleanTitle)) || strings.Contains(strings.ToLower(r.Track.Title), strings.ToLower(track.CleanTitle)) + 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 } diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index f12dd044..8832c07d 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -84,7 +84,8 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } monitoredTime := currentTime.Sub(tracker.LastUpdated) - 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 From 88225e7d591009eb61f49df57b78fdadc9b0089f Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 17:57:18 +0300 Subject: [PATCH 12/39] add pagination and some fixes --- src/config/config.go | 2 +- src/downloader/lidarr.go | 76 +++++++++++++++++++++++---------------- src/downloader/monitor.go | 1 + 3 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index dc99703f..90bb12e1 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -153,7 +153,7 @@ type SlskdMon struct { 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 + 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"` diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 4d1d9219..6eed28b7 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -253,7 +253,7 @@ func (c Lidarr) GetTrack(track *models.Track) error { return fmt.Errorf("failed to trigger album search: %w", err) } - if err := c.searchStatus(albumID, 0); err != nil { + if err := c.searchStatus(albumID, 1); err != nil { return fmt.Errorf("album search failed: %w", err) } track.File = track.Title @@ -299,13 +299,13 @@ func (c Lidarr) GetTrack(track *models.Track) error { return fmt.Errorf("failed to unmarshal lidarr album: %w", err) } - if err := c.searchStatus(album.ID, 0); err != nil { + 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") + slog.Info("download started", "AlbumID", track.ID) return nil } // Check whether album search is completed @@ -336,36 +336,28 @@ func (c *Lidarr) searchStatus(AlbumID, count int) error { 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(10 * time.Second) + time.Sleep(15 * time.Second) return c.searchStatus(AlbumID, count+1) } func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { - req := "/api/v1/queue" - - 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 - } - + 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 != "" { - fmt.Printf("lidarr downloaded: %s, %s\n",track.Album, file) statuses[track.ID] = FileStatus{ ID: track.ID, Filename: file, @@ -378,20 +370,44 @@ func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatu } } + 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) - for _, record := range queue.Records { - ID := strconv.Itoa(record.AlbumID) - if _, e := statuses[ID]; e { - continue + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, c.Headers) + if err != nil { + return nil, err } - - 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), + + 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), + } } } diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 8832c07d..57fc0d92 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -49,6 +49,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err for range ticker.C { statuses, err := m.GetDownloadStatus(tracks) + fmt.Println(statuses) if err != nil { return fmt.Errorf("[%s/monitor] error fetching download status: %s", monCfg.Service, err.Error()) } From 3e1ed830b0a99a863476cc51f19e3637065818fc Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 19:35:10 +0300 Subject: [PATCH 13/39] replace print with slog --- src/downloader/monitor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 57fc0d92..b9b8e884 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -49,10 +49,10 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err for range ticker.C { statuses, err := m.GetDownloadStatus(tracks) - fmt.Println(statuses) 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() From ff4435898a685ea194f0df04ee7f9dc913fb7dec Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 19:48:09 +0300 Subject: [PATCH 14/39] add rootfolder search by name --- src/config/config.go | 1 + src/downloader/lidarr.go | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 90bb12e1..da9c68c3 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -158,6 +158,7 @@ type Lidarr struct { 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 } diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 6eed28b7..2959462c 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -105,6 +105,7 @@ type Data struct { } type RootFolder struct { + Name string `json:"name"` Path string `json:"path"` DefaultMetadataProfileId int `json:"defaultMetadataProfileId"` DefaultQualityProfileId int `json:"defaultQualityProfileId"` @@ -456,8 +457,12 @@ func (c Lidarr) getRootDirectory() (*RootFolder, error) { if len(rootFolders) == 0 { return nil, fmt.Errorf("no root folders found in Lidarr") } - rootFolder := rootFolders[0] - return &rootFolder, nil + 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 { From b1e197aca984b9fe3dc8c1cf96ed444686c8140d Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:06:51 -0700 Subject: [PATCH 15/39] wire LidarrURL + LidarrAPIKey to wizard handler, persist MBID's through playlist cache --- src/discovery/listenbrainz.go | 7 +++++++ src/main/main.go | 22 ++++++++++++++++++++-- src/web/backend/playlist/playlist.go | 13 ++++++++----- src/web/backend/settings/handlers.go | 4 ++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index e7ed020c..993b5c2d 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -658,6 +658,13 @@ func FetchTopRecordings(httpClient *util.HttpClient, user string) ([]*models.Tra return lb.getTopRecordings(user) } +// EnrichTracks fetches MusicBrainz metadata for tracks that have recording MBIDs, +// populating fields like MusicBrainzReleaseGroupID and MusicBrainzArtistID. +func EnrichTracks(httpClient *util.HttpClient, lbCfg cfg.Listenbrainz, tracks []*models.Track) ([]*models.Track, error) { + lb := &ListenBrainz{HttpClient: httpClient, cfg: lbCfg} + return lb.enrichTracks(tracks, lbCfg.SingleArtist) +} + // FetchMostRecentPlaylistByType finds and fetches the most recent LB-generated playlist of the given type for the user. func FetchMostRecentPlaylistByType(httpClient *util.HttpClient, user, playlistType string) ([]*models.Track, error) { lb := &ListenBrainz{HttpClient: httpClient, cfg: cfg.Listenbrainz{ImportPlaylist: playlistType}} diff --git a/src/main/main.go b/src/main/main.go index 6b32e5e3..19f9823f 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -35,7 +35,8 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro MainArtist string `json:"mainArtist"` Release string `json:"release"` CoverURL string `json:"coverUrl"` - CoverPath string `json:"coverPath"` + CoverPath string `json:"coverPath"` + MusicBrainzTrackID string `json:"mbTrackId,omitempty"` } type cacheFile struct { Tracks []cachedTrack `json:"tracks"` @@ -81,7 +82,8 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro MainArtist: mainArtist, Album: t.Release, CoverURL: t.CoverURL, - CoverPath: t.CoverPath, + CoverPath: t.CoverPath, + MusicBrainzTrackID: t.MusicBrainzTrackID, } } return tracks, name, nil @@ -174,6 +176,22 @@ func main() { tracks, playlistName, err = loadCustomTracks(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist) if err == nil { cfg.ClientCfg.PlaylistName = playlistName + hasMBIDs := func() bool { + for _, t := range tracks { + if t.MusicBrainzTrackID != "" { + return true + } + } + return false + }() + if cfg.DiscoveryCfg.Listenbrainz.EnrichTrackMetadata && hasMBIDs { + enriched, enrichErr := discovery.EnrichTracks(httpClient, cfg.DiscoveryCfg.Listenbrainz, tracks) + if enrichErr != nil { + slog.Warn("failed to enrich custom playlist metadata", "error", enrichErr) + } else { + tracks = enriched + } + } } } else { disc := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) diff --git a/src/web/backend/playlist/playlist.go b/src/web/backend/playlist/playlist.go index 88b26181..d1cd494d 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -31,7 +31,8 @@ type PlaylistTrack struct { Artist string MainArtist string Album string - CoverURL string + CoverURL string + MusicBrainzTrackID string } @@ -86,7 +87,8 @@ func modelTracksToPlaylistTracks(tracks []*models.Track) []PlaylistTrack { Artist: t.Artist, MainArtist: t.MainArtist, Album: t.Album, - CoverURL: t.CoverURL, + CoverURL: t.CoverURL, + MusicBrainzTrackID: t.MusicBrainzTrackID, } } return out @@ -169,7 +171,8 @@ type cachedPrefetchTrack struct { MainArtist string `json:"mainArtist,omitempty"` Release string `json:"release"` CoverURL string `json:"coverUrl,omitempty"` - CoverPath string `json:"coverPath,omitempty"` + CoverPath string `json:"coverPath,omitempty"` + MusicBrainzTrackID string `json:"mbTrackId,omitempty"` } // writePreliminaryCache writes the track cache with remote cover URLs immediately. @@ -177,7 +180,7 @@ type cachedPrefetchTrack struct { func writePreliminaryCache(cfgDir, playlistType string, tracks []PlaylistTrack) bool { ct := make([]cachedPrefetchTrack, len(tracks)) for i, t := range tracks { - ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: t.CoverURL} + ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: t.CoverURL, MusicBrainzTrackID: t.MusicBrainzTrackID} } if !writeTrackCache(cfgDir, playlistType, ct) { return false @@ -197,7 +200,7 @@ func downloadAndCacheCovers(cfgDir, playlistType string, tracks []PlaylistTrack) ct := make([]cachedPrefetchTrack, len(tracks)) for i, t := range tracks { APIPath, coverPath := util.DownloadCover(t.CoverURL, coversDir) - ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath} + ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath, MusicBrainzTrackID: t.MusicBrainzTrackID} } if writeTrackCache(cfgDir, playlistType, ct) { slog.Info("prefetch: cache updated", "playlist", playlistType, "covers", "local") diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 513baf27..43ceb36d 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -438,6 +438,8 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { FilterList string `json:"filter_list"` SlskdURL string `json:"slskd_url"` SlskdAPIKey string `json:"slskd_api_key"` + 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 { @@ -468,6 +470,8 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { "FILTER_LIST": body.FilterList, "SLSKD_URL": body.SlskdURL, "SLSKD_API_KEY": body.SlskdAPIKey, + "LIDARR_URL": body.LidarrURL, + "LIDARR_API_KEY": body.LidarrAPIKey, "EXTENSIONS": body.Extensions, // slskd "WIZARD_COMPLETE": "true", } From 4d15c33469f3723b2b04e872a2c9b95464d52462 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:33:20 -0700 Subject: [PATCH 16/39] add ForeignArtistID to Album struct and update getReleaseGroupId logic --- src/downloader/lidarr.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 2959462c..d1d453a1 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -28,6 +28,9 @@ type Album struct { Title string `json:"title"` ArtistID int `json:"artistId"` ForeignAlbumID string `json:"foreignAlbumId"` + Artist struct { + ForeignArtistID string `json:"foreignArtistId"` + } `json:"artist"` } type LidarrTrack struct { @@ -466,7 +469,7 @@ func (c Lidarr) getRootDirectory() (*RootFolder, error) { } func (c Lidarr) getReleaseGroupId(track *models.Track) error { - if track.MusicBrainzReleaseGroupID != "" { + if track.MusicBrainzReleaseGroupID != "" && track.MusicBrainzArtistID != "" { return nil } @@ -488,6 +491,7 @@ func (c Lidarr) getReleaseGroupId(track *models.Track) error { } track.MusicBrainzReleaseGroupID = albums[0].ForeignAlbumID + track.MusicBrainzArtistID = albums[0].Artist.ForeignArtistID return nil } From 92be129ee408e02462ee4b667ac20fa811c152ef Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 17:36:26 +0300 Subject: [PATCH 17/39] save root dir when initializing lidarr --- src/downloader/downloader.go | 3 +++ src/downloader/lidarr.go | 25 +++++++++++-------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 684434c5..551af44f 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -47,6 +47,9 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL case "lidarr": lidarrClient := NewLidarr(cfg.Lidarr, cfg.DownloadDir) lidarrClient.AddHeader() + if err := lidarrClient.getRootDirectory(); err != nil { + return nil, err + } downloader = append(downloader, lidarrClient) default: return nil, fmt.Errorf("downloader '%s' not supported", service) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index d1d453a1..e7079148 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -21,6 +21,7 @@ type Lidarr struct { DownloadDir string HttpClient *util.HttpClient Cfg cfg.Lidarr + RootFolder RootFolder } type Album struct { @@ -264,20 +265,15 @@ func (c Lidarr) GetTrack(track *models.Track) error { 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, + "qualityProfileId": c.RootFolder.DefaultQualityProfileId, + "metadataProfileId": c.RootFolder.DefaultMetadataProfileId, "foreignArtistId": track.MusicBrainzArtistID, - "rootFolderPath": rootFolder.Path, + "rootFolderPath": c.RootFolder.Path, }, "addOptions": AddOptions{ SearchForNewAlbum: true, @@ -444,28 +440,29 @@ func (c *Lidarr) checkHistory(track models.Track) (string, error) { } -func (c Lidarr) getRootDirectory() (*RootFolder, error) { +func (c *Lidarr) getRootDirectory() 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 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) + return fmt.Errorf("failed to unmarshal query root folder: %w", err) } if len(rootFolders) == 0 { - return nil, fmt.Errorf("no root folders found in Lidarr") + return fmt.Errorf("no root folders found in Lidarr") } for _, folder := range rootFolders { if folder.Name == c.Cfg.RootFolder { - return &folder, nil + c.RootFolder = folder + return 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) + return 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 { From abec9790d89dc7cde4823349f241fd58e3af458a Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 17:42:42 +0300 Subject: [PATCH 18/39] Track ID based lidarr monitoring --- src/downloader/lidarr.go | 138 ++++++++++++++++++++++----------------- src/models/types.go | 1 + 2 files changed, 80 insertions(+), 59 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index e7079148..f4d7c947 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -341,35 +341,86 @@ func (c *Lidarr) searchStatus(AlbumID, count int) error { } 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 + + albums := make(map[string][]*models.Track) + for _, track := range tracks { - if track.ID == "" || track.Present { + if track.ID == "" || track.AlbumID == "" || track.Present { continue } - - requested[track.ID] = struct{}{} - - file, err := c.checkHistory(*track) + albums[track.AlbumID] = append(albums[track.AlbumID], track) + } + statuses := make(map[string]FileStatus) + queues, err := c.getQueue() if err != nil { - slog.Warn("failed to check download history", "err", err) + slog.Warn("[lidarr] failed to check queue", "err", err) } - if file != "" { + queueMap := make(map[string]LidarrQueueItem) + + for _, queue := range queues { + for _, record := range queue.Records { + queueMap[strconv.Itoa(record.AlbumID)] = record + } + } + + // check for completed downloads and build map of tracks that might be in queue + for albumID, albumTracks := range albums { + + history ,err := c.getHistory(albumID) + if err != nil { + slog.Warn("[lidarr] failed to check download history", "err", err) + } + + for _, r := range history.Records { + for _, track := range albumTracks { + if _, exists := statuses[track.ID]; exists { + continue + } + trackIDMatch := track.ID == strconv.Itoa(r.TrackID) + mbTrack := track.MusicBrainzTrackID + mbReleaseTrack := track.MusicBrainzReleaseTrackID + mbIDMatch := (mbTrack != "" && mbTrack == r.Track.ForeignTrackID) || (mbReleaseTrack != "" && mbReleaseTrack == r.Track.ForeignTrackID) + if (mbIDMatch || trackIDMatch) && r.Data.ImportedPath != "" { + statuses[track.ID] = FileStatus{ + ID: track.ID, + Filename: r.Data.ImportedPath, + State: "Succeeded", + BytesTransferred: 1, + BytesRemaining: 0, + PercentComplete: 100, + QueueID: "", + } + } + } + } + + record, ok := queueMap[albumID] + if !ok { + continue + } + + for _, track := range albumTracks { + if _, e := statuses[track.ID]; e { + continue + } + // assign all tracks in an album the same state statuses[track.ID] = FileStatus{ - ID: track.ID, - Filename: file, - State: "Succeeded", - BytesTransferred: 1, - BytesRemaining: 0, - PercentComplete: 100, - QueueID: "", + ID: track.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 +} + +func (c *Lidarr) getQueue() ([]LidarrQueue, error) { + var totalPages = 1 pageSize := 50 var queues []LidarrQueue @@ -388,56 +439,25 @@ func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatu 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 + return queues, 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) +// Queries Lidarr history for specified album +func (c *Lidarr) getHistory(albumID string) (LidarrHistory, error) { + + req := fmt.Sprintf("/api/v1/history?albumId=%s&pageSize=1111", albumID) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, c.Headers) if err != nil { - return "", err + return LidarrHistory{}, 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 LidarrHistory{}, err } - return "", nil + return history, nil } func (c *Lidarr) getRootDirectory() error { diff --git a/src/models/types.go b/src/models/types.go index f3656baf..27999573 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -4,6 +4,7 @@ package models type Track struct { Album string + AlbumID string // Used by Lidarr AlbumArtist string ID string Artist string // All artists as returned by LB From 85546d07fbe07bacef51e8b3f7585fe6090977a7 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 17:46:59 +0300 Subject: [PATCH 19/39] create album cache --- src/downloader/lidarr.go | 44 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index f4d7c947..475ecfe3 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -9,6 +9,7 @@ import ( "slices" "strconv" "strings" + "sync" "time" cfg "explo/src/config" @@ -21,7 +22,16 @@ type Lidarr struct { DownloadDir string HttpClient *util.HttpClient Cfg cfg.Lidarr - RootFolder RootFolder + AlbumCache map[string]*AlbumMetadata + CacheMu sync.RWMutex + RootFolder RootFolder +} + +type AlbumMetadata struct { + AlbumID string + ArtistID string + ReleaseGroupMBID string + ArtistMBID string } type Album struct { @@ -538,3 +548,35 @@ func (c *Lidarr) Cleanup(track models.Track, queueID string) error { } return nil } + +func (c *Lidarr) cacheKey(track *models.Track) string { + return track.MainArtist + "|" + track.Album +} + +func (c *Lidarr) cacheAlbum(track *models.Track) { + key := c.cacheKey(track) + c.CacheMu.Lock() + defer c.CacheMu.Unlock() + c.AlbumCache[key] = &AlbumMetadata{ + ReleaseGroupMBID: track.MusicBrainzReleaseGroupID, + ArtistMBID: track.MusicBrainzArtistID, + AlbumID: track.AlbumID, + ArtistID: track.MainArtistID, + } +} +// Checks if album is cached. Populates track fields if it is +func (c *Lidarr) populateFromCache(track *models.Track) bool { + c.CacheMu.RLock() + a, ok := c.AlbumCache[c.cacheKey(track)] + c.CacheMu.RUnlock() + + if !ok { + return false + } + + track.MusicBrainzReleaseGroupID = a.ReleaseGroupMBID + track.MusicBrainzArtistID = a.ArtistMBID + track.AlbumID = a.AlbumID + track.MainArtistID = a.ArtistID + return true +} From a9a5df5a6062667e089a112adbe05d93ba692210 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 17:50:40 +0300 Subject: [PATCH 20/39] cache tracks from lidarr response --- src/downloader/lidarr.go | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 475ecfe3..e75246c5 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -32,6 +32,8 @@ type AlbumMetadata struct { ArtistID string ReleaseGroupMBID string ArtistMBID string + + Tracks func() []LidarrTrack } type Album struct { @@ -318,6 +320,57 @@ func (c Lidarr) GetTrack(track *models.Track) error { slog.Info("download started", "AlbumID", track.ID) return nil } + +func (c *Lidarr) getAlbumTracks(albumID string, artistID string) []LidarrTrack { + queryURL := fmt.Sprintf("%s/api/v1/track?albumId=%s&artistId=%s", c.Cfg.URL, albumID, artistID) + for attempt := 1; attempt <= 3; attempt++ { + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err == nil { + var tracks []LidarrTrack + if err := util.ParseResp(body, &tracks); err == nil { + return tracks + } + } + slog.Warn("failed loading album tracks", "attempt", attempt, "err", err) + + if attempt < 3 { + time.Sleep(time.Second) + } + } + return nil +} + +func (c *Lidarr) findTrackID(track *models.Track) error { + key := c.cacheKey(track) + + c.CacheMu.RLock() + a, ok := c.AlbumCache[key] + c.CacheMu.RUnlock() + + if !ok { + return fmt.Errorf("album not cached") + } + + alnumTrackTitle := util.AlnumOnly(track.CleanTitle) + for _, t := range a.Tracks() { + mbIDMatch := (track.MusicBrainzReleaseTrackID != "" && track.MusicBrainzReleaseTrackID == t.ForeignTrackID) || + (track.MusicBrainzTrackID != "" && track.MusicBrainzTrackID == t.ForeignRecordingID) + alnumLidarrTitle := util.AlnumOnly(t.Title) + fmt.Printf("comparing Lidarr title: %s\n track title: %s\n Lidarr TrackID: %s\nTrack MBID: %s", alnumLidarrTitle, alnumTrackTitle, t.ForeignRecordingID, track.MusicBrainzTrackID) + titleMatch := util.ContainsFold(alnumLidarrTitle, alnumTrackTitle) + + if mbIDMatch || titleMatch { + if t.HasFile { + track.Present = true + slog.Info("track already present in Lidarr", "track", track.CleanTitle, "album", track.Album, "artist", track.MainArtist) + } + track.ID = strconv.Itoa(t.ID) + return nil + } + } + return fmt.Errorf("could not find track '%s' from Album: %s", track.CleanTitle, track.Album) +} + // Check whether album search is completed func (c *Lidarr) searchStatus(AlbumID, count int) error { reqParams := "/api/v1/command" @@ -562,6 +615,10 @@ func (c *Lidarr) cacheAlbum(track *models.Track) { ArtistMBID: track.MusicBrainzArtistID, AlbumID: track.AlbumID, ArtistID: track.MainArtistID, + + Tracks: sync.OnceValue(func() []LidarrTrack { + return c.getAlbumTracks(track.AlbumID, track.MainArtistID) + }), } } // Checks if album is cached. Populates track fields if it is From d7862cf9bb610ac128f1066defd2562675f5f7bf Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 17:52:40 +0300 Subject: [PATCH 21/39] split GetTrack to subfunctions --- src/downloader/lidarr.go | 76 ++++++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index e75246c5..0b8c4eff 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -240,8 +240,7 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { return nil } -func (c Lidarr) GetTrack(track *models.Track) error { - +func (c *Lidarr) GetTrack(track *models.Track) error { slog.Info("downloading track", "title", track.Title, "artist", track.Artist, @@ -251,32 +250,46 @@ func (c Lidarr) GetTrack(track *models.Track) error { return nil } - if track.ID != "" { - albumID, err := strconv.Atoi(track.ID) + if c.populateFromCache(track) { + return c.findTrackID(track) + } + + if track.AlbumID != "" { + albumID, err := strconv.Atoi(track.AlbumID) 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 { + + if err = c.triggerAlbumSearch(albumID); err != nil { return fmt.Errorf("failed to trigger album search: %w", err) } + c.cacheAlbum(track) + if err := c.searchStatus(albumID, 1); err != nil { return fmt.Errorf("album search failed: %w", err) } - track.File = track.Title + + track.File = track.CleanTitle return nil } + if err := c.addNewAlbum(track); err != nil { + return fmt.Errorf("failed to add album: %w", err) + } + + if c.populateFromCache(track) { + return c.findTrackID(track) + } + track.File = track.CleanTitle + + slog.Info("download started", "AlbumID", track.AlbumID) + return nil +} + +func (c *Lidarr) addNewAlbum(track *models.Track) error { + payload := map[string]any{ "foreignAlbumId": track.MusicBrainzReleaseGroupID, "monitored": true, @@ -300,24 +313,41 @@ func (c Lidarr) GetTrack(track *models.Track) error { 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 + if strings.Contains(err.Error(), "got 409") || strings.Contains(string(body), "has already been added") { + if _, ok := c.AlbumCache[c.cacheKey(track)]; ok { + slog.Debug("album already in Lidarr", "album", track.MusicBrainzReleaseGroupID) + return nil + } + return fmt.Errorf("album present in lidarr but explo couldn't find it") } - return fmt.Errorf("failed to add album: %w", err) + return err } var album Album if err = util.ParseResp(body, &album); err != nil { return fmt.Errorf("failed to unmarshal lidarr album: %w", err) } - + track.AlbumID = strconv.Itoa(album.ID) + track.MainArtistID = strconv.Itoa(album.ArtistID) + c.cacheAlbum(track) if err := c.searchStatus(album.ID, 1); err != nil { return fmt.Errorf("album search failed: %w", err) } + return nil +} - track.ID = strconv.Itoa(album.ID) - track.File = track.CleanTitle - slog.Info("download started", "AlbumID", track.ID) +func (c *Lidarr) triggerAlbumSearch(albumID int) error { + payload := map[string]any{ + "name": "AlbumSearch", + "albumIds": []int{albumID}, + } + payloadBody, 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(payloadBody), c.Headers) + if err != nil { + return err + } return nil } From ca75a3d62332bd39512a793605f0ad251ba9d92d Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 17:53:49 +0300 Subject: [PATCH 22/39] check cache before searching track --- src/downloader/lidarr.go | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 0b8c4eff..9374078a 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -194,6 +194,10 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { trackDetails := fmt.Sprintf("%s - %s", track.Title, track.Artist) slog.Info("initiating search", "track", trackDetails) + if c.populateFromCache(track) { + return c.findTrackID(track) +} + 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) } @@ -215,28 +219,16 @@ 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) - 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 - } + track.AlbumID = strconv.Itoa(libraryAlbum.ID) + track.MainArtistID = strconv.Itoa(libraryAlbum.ArtistID) + + c.cacheAlbum(track) + + slog.Info("album found in Lidarr library", "album", libraryAlbum.Title, "id", libraryAlbum.ID) + if err := c.findTrackID(track); err != nil { + return fmt.Errorf("failed to get track ID: %w", err) } - return nil } From 04d9ae796a6b5d4977d091ae10bb850cae4d5c00 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 17:57:15 +0300 Subject: [PATCH 23/39] add pointe --- src/downloader/lidarr.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 9374078a..a1be441f 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -570,7 +570,7 @@ func (c *Lidarr) getRootDirectory() error { return 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 { +func (c *Lidarr) getReleaseGroupId(track *models.Track) error { if track.MusicBrainzReleaseGroupID != "" && track.MusicBrainzArtistID != "" { return nil } @@ -604,7 +604,7 @@ func percent(total, remaining int64) float64 { return float64(total-remaining) / float64(total) * 100 } -func (c Lidarr) deleteDownload(ID string) error { +func (c *Lidarr) deleteDownload(ID string) error { reqParams := fmt.Sprintf("/api/v1/queue/%s", ID) time.Sleep(1 * time.Second) From 570552c402a267a186b9b6f6f9779c49db916525 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 19:30:49 +0300 Subject: [PATCH 24/39] move MoveDownload under monitor struct --- src/config/config.go | 63 ++++++++++++++++---------- src/downloader/lidarr.go | 5 +++ src/downloader/monitor.go | 7 +-- src/downloader/slskd.go | 95 +++++++++++++++++++++++++++++++++++++++ src/downloader/youtube.go | 5 +++ 5 files changed, 148 insertions(+), 27 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index da9c68c3..3bdb8da5 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -93,15 +93,15 @@ type SubsonicConfig struct { } type DownloadConfig struct { - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - PathTemplate string `env:"PATH_TEMPLATE"` + DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` + PathTemplate string `env:"PATH_TEMPLATE"` Youtube Youtube YoutubeMusic YoutubeMusic Slskd Slskd - Lidarr Lidarr + 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 + 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 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 UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"` @@ -135,37 +135,42 @@ type YoutubeMusic struct { } type Slskd struct { - APIKey string `env:"SLSKD_API_KEY"` - URL string `env:"SLSKD_URL"` - Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track - DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track - SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` - MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir - Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` - Filters Filters - MonitorConfig SlskdMon + APIKey string `env:"SLSKD_API_KEY"` + URL string `env:"SLSKD_URL"` + Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` + MigrateDL bool `env:"SLSKD_MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir + MigrateDLOld bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir (Old) + RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format (will be deprecated) + PathTemplate string `env:"PATH_TEMPLATE"` + OverwriteMetadata bool `env:"SLSKD_OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads + KeepPermissions bool `env:"SLSKD_KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download + Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` + Filters Filters + MonitorConfig SlskdMon } type SlskdMon struct { - Interval int `env:"SLSKD_MONITOR_INTERVAL" env-default:"1"` - Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` + Interval int `env:"SLSKD_MONITOR_INTERVAL" env-default:"1"` // in minutes + Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` // in minutes } type Lidarr struct { APIKey string `env:"LIDARR_API_KEY"` + URL string `env:"LIDARR_URL"` 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"` + Interval int `env:"LIDARR_MONITOR_INTERVAL" env-default:"1"` // in minutes + Duration int `env:"LIDARR_MONITOR_DURATION" env-default:"20"` // in minutes } type DiscoveryConfig struct { @@ -272,11 +277,9 @@ func fixBaseURL(rawURL string) string { return strings.TrimRight(u, "/") } -func (cfg *Config) HandleDeprecation() { // - if cfg.Debug { - slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") - cfg.LogLevel = "DEBUG" - } + +// deprecation logs and remappings +func (cfg *Config) HandleDeprecation() { if cfg.Flags.PersistSet { slog.Warn("--persist has been deprecated since v1.1.3, please use --replace-playlist") } @@ -287,6 +290,18 @@ func (cfg *Config) HandleDeprecation() { // if cfg.Flags.CleanDownloads && !cfg.DownloadCfg.UseSubDir { slog.Warn("Deleting tracks requires 'USE_SUBDIRECTORY' to be true") } + + if cfg.DownloadCfg.OverwriteMetadata { + cfg.DownloadCfg.Slskd.OverwriteMetadata = cfg.DownloadCfg.OverwriteMetadata + } + + if !cfg.DownloadCfg.KeepPermissions { + cfg.DownloadCfg.Slskd.KeepPermissions = cfg.DownloadCfg.KeepPermissions + } + + if cfg.DownloadCfg.Slskd.MigrateDLOld { + cfg.DownloadCfg.Slskd.MigrateDL = cfg.DownloadCfg.Slskd.MigrateDLOld + } } // Generate playlist name and description diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index a1be441f..7e3efb1f 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -659,3 +659,8 @@ func (c *Lidarr) populateFromCache(track *models.Track) bool { track.MainArtistID = a.ArtistID return true } + + +func (c *Lidarr) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { + return nil +} \ No newline at end of file diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index b9b8e884..f41d453c 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -12,6 +12,7 @@ import ( type Monitor interface { GetDownloadStatus([]*models.Track) (map[string]FileStatus, error) GetConf() (MonitorConfig, error) + MoveDownload(string, string, string, *models.Track) error Cleanup(models.Track, string) error } @@ -89,10 +90,10 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err track.File = fileStatus.Filename track.Present = true slog.Info("[monitor] file downloaded successfully", "service", monCfg.Service, "file", track.File) - var path string - track.File, path = parsePath(track.File) + var filePath string + track.File, filePath = parsePath(track.File) if monCfg.MigrateDownload { - if err = c.MoveDownload(monCfg.FromDir, monCfg.ToDir, path, track); err != nil { + if err = m.MoveDownload(monCfg.FromDir, monCfg.ToDir, filePath, track); err != nil { slog.Error("error while moving file", "err", err.Error()) } else { slog.Info("track moved successfully", "service", monCfg.Service) diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index 0708786f..94093540 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -14,6 +14,8 @@ import ( "slices" "strings" "time" + "os" + "io" ) type Search struct { @@ -487,3 +489,96 @@ func normalize(state string) string{ } return state } + + +func (c *Slskd) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { + trackDir := filepath.Join(srcDir, trackPath) + srcFile := filepath.Join(trackDir, track.File) + + if c.Cfg.RenameTrack { // Rename file to {title}-{artist} format + track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) + } + if c.Cfg.OverwriteMetadata { + metadata := util.BuildffmpegMetadata(*track) + if err := overwriteMetadata(metadata, srcFile); err != nil { + slog.Warn("problem overwriting metadata", "msg", err.Error()) + } + } + + in, err := os.Open(srcFile) + if err != nil { + return fmt.Errorf("couldn't open source file: %s", err.Error()) + } + + defer func() { + if cerr := in.Close(); cerr != nil { + slog.Error(fmt.Sprintf("failed to close source file: %s", cerr.Error())) + } + }() + + var dstFile string + + if c.Cfg.PathTemplate != "" { + relativePath := buildTrackPath(c.Cfg.PathTemplate, track) + track.File = filepath.Base(relativePath) + if track.File == "." || track.File == string(filepath.Separator) { + track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) + relativePath = filepath.Dir(relativePath) + string(filepath.Separator) + track.File + slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) + } + dstFile = filepath.Join(destDir, relativePath) + } else { + if err = os.MkdirAll(destDir, os.ModePerm); err != nil { + return fmt.Errorf("couldn't make download directory: %s", err.Error()) + } + + dstFile = filepath.Join(destDir, track.File) + } + if err = os.MkdirAll(filepath.Dir(dstFile), os.ModePerm); err != nil { + return fmt.Errorf("couldn't make destination directory: %s", err.Error()) + } + + out, err := os.Create(dstFile) + if err != nil { + return fmt.Errorf("couldn't create destination file: %s", err.Error()) + } + + defer func() { + if cerr := out.Close(); cerr != nil { + slog.Error(fmt.Sprintf("failed to close destination file: %s", cerr.Error())) + } + }() + + if _, err = io.Copy(out, in); err != nil { + return fmt.Errorf("copy failed: %s", err.Error()) + } + + if err = out.Sync(); err != nil { + return fmt.Errorf("sync failed: %s", err.Error()) + } + + if c.Cfg.KeepPermissions { + info, err := os.Stat(srcFile) + if err != nil { + return fmt.Errorf("stat error: %s", err.Error()) + } + if err = os.Chmod(dstFile, info.Mode()); err != nil { + return fmt.Errorf("chmod failed: %s", err.Error()) + } + } + + if err = os.Remove(srcFile); err != nil { + return fmt.Errorf("failed to delete original file: %s", err.Error()) + } + + isEmpty, err := isDirEmpty(trackDir) + if err != nil { + return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) + } else if isEmpty { + if err = os.Remove(trackDir); err != nil { + return fmt.Errorf("failed to remove empty directory: %s", err.Error()) + } + } + + return nil +} \ No newline at end of file diff --git a/src/downloader/youtube.go b/src/downloader/youtube.go index f1497add..867be355 100644 --- a/src/downloader/youtube.go +++ b/src/downloader/youtube.go @@ -294,3 +294,8 @@ func (c *Youtube) GetDownloadStatus(tracks []*models.Track) (map[string]FileStat func (c *Youtube) Cleanup(track models.Track, ID string) error { return nil } + +func (c *Youtube) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { + + return nil +} From d215c02c8b35f839c79b952bfac37ffea20245d4 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 19:35:53 +0300 Subject: [PATCH 25/39] remove MoveDownload from downloader --- src/downloader/downloader.go | 92 ------------------------------------ 1 file changed, 92 deletions(-) diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 551af44f..3d9c0000 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -256,98 +256,6 @@ func buildTrackPath(template string, track *models.Track) string { return filepath.Clean(result) } -func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { - trackDir := filepath.Join(srcDir, trackPath) - srcFile := filepath.Join(trackDir, track.File) - - if c.Cfg.RenameTrack { // Rename file to {title}-{artist} format - track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) - } - if c.Cfg.OverwriteMetadata { - metadata := util.BuildffmpegMetadata(*track) - if err := overwriteMetadata(metadata, srcFile); err != nil { - slog.Warn("problem overwriting metadata", "msg", err.Error()) - } - } - - in, err := os.Open(srcFile) - if err != nil { - return fmt.Errorf("couldn't open source file: %s", err.Error()) - } - - defer func() { - if cerr := in.Close(); cerr != nil { - slog.Error(fmt.Sprintf("failed to close source file: %s", cerr.Error())) - } - }() - - var dstFile string - - if c.Cfg.PathTemplate != "" { - relativePath := buildTrackPath(c.Cfg.PathTemplate, track) - track.File = filepath.Base(relativePath) - if track.File == "." || track.File == string(filepath.Separator) { - track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) - relativePath = filepath.Dir(relativePath) + string(filepath.Separator) + track.File - slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) - } - dstFile = filepath.Join(destDir, relativePath) - } else { - if err = os.MkdirAll(destDir, os.ModePerm); err != nil { - return fmt.Errorf("couldn't make download directory: %s", err.Error()) - } - - dstFile = filepath.Join(destDir, track.File) - } - if err = os.MkdirAll(filepath.Dir(dstFile), os.ModePerm); err != nil { - return fmt.Errorf("couldn't make destination directory: %s", err.Error()) - } - - out, err := os.Create(dstFile) - if err != nil { - return fmt.Errorf("couldn't create destination file: %s", err.Error()) - } - - defer func() { - if cerr := out.Close(); cerr != nil { - slog.Error(fmt.Sprintf("failed to close destination file: %s", cerr.Error())) - } - }() - - if _, err = io.Copy(out, in); err != nil { - return fmt.Errorf("copy failed: %s", err.Error()) - } - - if err = out.Sync(); err != nil { - return fmt.Errorf("sync failed: %s", err.Error()) - } - - if c.Cfg.KeepPermissions { - info, err := os.Stat(srcFile) - if err != nil { - return fmt.Errorf("stat error: %s", err.Error()) - } - if err = os.Chmod(dstFile, info.Mode()); err != nil { - return fmt.Errorf("chmod failed: %s", err.Error()) - } - } - - if err = os.Remove(srcFile); err != nil { - return fmt.Errorf("failed to delete original file: %s", err.Error()) - } - - isEmpty, err := isDirEmpty(trackDir) - if err != nil { - return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) - } else if isEmpty { - if err = os.Remove(trackDir); err != nil { - return fmt.Errorf("failed to remove empty directory: %s", err.Error()) - } - } - - return nil -} - func overwriteMetadata(metadata []string, srcFile string) error { opts := ffmpeg.KwArgs{ "c": "copy", From 5701fd141937ac53988ac766a9e6d15246084416 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 31 Jul 2026 20:04:23 +0300 Subject: [PATCH 26/39] remove comparison --- src/downloader/lidarr.go | 1 - 1 file changed, 1 deletion(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 7e3efb1f..da8686e9 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -378,7 +378,6 @@ func (c *Lidarr) findTrackID(track *models.Track) error { mbIDMatch := (track.MusicBrainzReleaseTrackID != "" && track.MusicBrainzReleaseTrackID == t.ForeignTrackID) || (track.MusicBrainzTrackID != "" && track.MusicBrainzTrackID == t.ForeignRecordingID) alnumLidarrTitle := util.AlnumOnly(t.Title) - fmt.Printf("comparing Lidarr title: %s\n track title: %s\n Lidarr TrackID: %s\nTrack MBID: %s", alnumLidarrTitle, alnumTrackTitle, t.ForeignRecordingID, track.MusicBrainzTrackID) titleMatch := util.ContainsFold(alnumLidarrTitle, alnumTrackTitle) if mbIDMatch || titleMatch { From 37643d0ff4426a48edb9a32a31d2bd96cd9682b6 Mon Sep 17 00:00:00 2001 From: LumePart Date: Thu, 6 Aug 2026 18:41:06 +0300 Subject: [PATCH 27/39] create albumcache on init --- src/downloader/lidarr.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index da8686e9..d0fd659c 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -169,6 +169,7 @@ func NewLidarr(cfg cfg.Lidarr, downloadDir string) *Lidarr { // init downloader Cfg: cfg, HttpClient: util.NewHttp(util.HttpClientConfig{Timeout: cfg.Timeout}), DownloadDir: downloadDir, + AlbumCache: make(map[string]*AlbumMetadata), } } From e0240de48cd601bb4688a099d4fb3091e614d0ee Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 7 Aug 2026 20:46:21 +0300 Subject: [PATCH 28/39] add post search album filtering --- src/downloader/lidarr.go | 82 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index d0fd659c..4ca8214f 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -39,11 +39,32 @@ type AlbumMetadata struct { type Album struct { ID int `json:"id"` Title string `json:"title"` + Disambiguation string `json:"disambiguation"` ArtistID int `json:"artistId"` ForeignAlbumID string `json:"foreignAlbumId"` - Artist struct { - ForeignArtistID string `json:"foreignArtistId"` - } `json:"artist"` + Artist LidarrAlbumArtist `json:"artist"` + Releases []LidarrReleases `json:"releases"` +} + +type LidarrAlbumArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + ArtistName string `json:"artistName"` + +} + +type LidarrReleases 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"` + MediumCount int `json:"mediumCount"` + Disambiguation string `json:"disambiguation"` + Country []string `json:"country"` + Label []string `json:"label"` + Format string `json:"format"` } type LidarrTrack struct { @@ -588,15 +609,64 @@ func (c *Lidarr) getReleaseGroupId(track *models.Track) error { return fmt.Errorf("failed to unmarshal lookup response: %w", err) } - if len(albums) == 0 { + releaseMBID, artistMBID := c.filterAlbumSearch(track.Album, track.MainArtist, albums) + if releaseMBID == "" || artistMBID == "" { return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist) } - track.MusicBrainzReleaseGroupID = albums[0].ForeignAlbumID - track.MusicBrainzArtistID = albums[0].Artist.ForeignArtistID + track.MusicBrainzReleaseGroupID = releaseMBID + track.MusicBrainzArtistID = artistMBID return nil } +func (c *Lidarr) filterAlbumSearch(albumName, mainArtist string, albums []Album) (string, string) { + cleanAlbum := util.AlnumOnly(albumName) + cleanArtist := util.AlnumOnly(mainArtist) + + bestScore := -1 + var bestAlbum *Album + for i := range albums { + album := &albums[i] + + cleanAlbumTitle := util.AlnumOnly(album.Title) + cleanArtistName := util.AlnumOnly(album.Artist.ArtistName) + albumMatch := util.ContainsFold(cleanAlbumTitle, cleanAlbum) || util.ContainsFold(cleanAlbum, cleanAlbumTitle) + artistMatch := util.ContainsFold(cleanArtistName, cleanArtist) || util.ContainsFold(cleanArtist, cleanArtistName) + + if !albumMatch || !artistMatch { + continue + } + albumScore := 0 + for _, release := range album.Releases { + score := 0 + if release.Format == "Digital Media" { + score++ + } + if slices.Contains(release.Country, "[Worldwide]") { + score++ + } + if strings.EqualFold(release.Status, "official") { + score++ + } + + if score > albumScore { + + albumScore = score + } + } + if albumScore > bestScore { + bestScore = albumScore + bestAlbum = album + } + + } + + if bestAlbum != nil { + return bestAlbum.ForeignAlbumID, bestAlbum.Artist.ForeignArtistID + } + return "", "" +} + func percent(total, remaining int64) float64 { if total == 0 { return 0 From 3386373232f2de30c39490fa788ea61792c97d52 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 7 Aug 2026 20:54:35 +0300 Subject: [PATCH 29/39] fall back to first available rootfolder, when not found by name --- src/downloader/lidarr.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 4ca8214f..cd0d2508 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -588,7 +588,11 @@ func (c *Lidarr) getRootDirectory() error { return nil } } - return fmt.Errorf("no root folder named '%s' found, please create one in Lidarr or point explo to a correct folder", c.Cfg.RootFolder) + + slog.Warn("specified root folder not found, please create one in Lidarr or point explo to a correct folder, falling back to first available folder", "expectedRootFolder", c.Cfg.RootFolder) + c.RootFolder = rootFolders[0] + + return nil } func (c *Lidarr) getReleaseGroupId(track *models.Track) error { From 82134a55d0e87e4f6b7740a55377a85bc9397657 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 23 Aug 2026 18:33:13 +0300 Subject: [PATCH 30/39] add preliminary album moving, improve album caching and fix a few bugs --- src/config/config.go | 2 + src/downloader/downloader.go | 42 +++++++ src/downloader/lidarr.go | 206 +++++++++++++++++++++++++++++------ src/downloader/monitor.go | 16 +-- src/downloader/slskd.go | 38 +------ 5 files changed, 224 insertions(+), 80 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 3bdb8da5..8cf49fab 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -164,6 +164,8 @@ type Lidarr struct { MigrateDL bool `env:"LIDARR_MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"` RootFolder string `env:"LIDARR_ROOT_FOLDER" env-default:"Music"` // Root folder name to use in Lidarr + PathTemplate string `env:"PATH_TEMPLATE"` + KeepPermissions bool `env:"LIDARR_KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download Filters Filters MonitorConfig LidarrMon } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 3d9c0000..52301646 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -283,6 +283,48 @@ func tempAudioFile(path string) string { return strings.TrimSuffix(path, ext) + ".tmp" + ext } +func moveFile(srcFile, dstFile string, keepPermissions bool) error { + in, err := os.Open(srcFile) + if err != nil { + return fmt.Errorf("couldn't open source file: %w", err) + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dstFile), 0755); err != nil { + return fmt.Errorf("couldn't create directory for file: %w", err) + } + out, err := os.Create(dstFile) + if err != nil { + return fmt.Errorf("couldn't create destination file: %w", err) + } + + if _, err := io.Copy(out, in); err != nil { + out.Close() + return fmt.Errorf("copy failed: %w", err) + } + + if err := out.Sync(); err != nil { + out.Close() + return fmt.Errorf("sync failed: %w", err) + } + + if err := out.Close(); err != nil { + return fmt.Errorf("failed to close destination file: %w", err) + } + + if keepPermissions { + info, err := os.Stat(srcFile) + if err != nil { + return fmt.Errorf("stat error: %w", err) + } + + if err := os.Chmod(dstFile, info.Mode()); err != nil { + return fmt.Errorf("chmod failed: %w", err) + } + } + + return os.Remove(srcFile) +} + func isDirEmpty(path string) (bool, error) { f, err := os.Open(path) if err != nil { diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index cd0d2508..587d6067 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -6,6 +6,9 @@ import ( "fmt" "log/slog" "net/url" + "maps" + "os" + "path/filepath" "slices" "strconv" "strings" @@ -30,19 +33,31 @@ type Lidarr struct { type AlbumMetadata struct { AlbumID string ArtistID string + + MainArtist string + Name string + ReleaseYear int ReleaseGroupMBID string ArtistMBID string + Moved bool Tracks func() []LidarrTrack + Files map[int]AlbumFile +} + +type AlbumFile struct { + Path string + Track LidarrTrack } -type Album struct { +type LidarrAlbum struct { ID int `json:"id"` Title string `json:"title"` Disambiguation string `json:"disambiguation"` ArtistID int `json:"artistId"` ForeignAlbumID string `json:"foreignAlbumId"` Artist LidarrAlbumArtist `json:"artist"` + ReleaseDate time.Time `json:"releaseDate"` Releases []LidarrReleases `json:"releases"` } @@ -230,7 +245,7 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { return fmt.Errorf("failed to check library for album: %w", err) } - var libraryAlbums []Album + var libraryAlbums []LidarrAlbum if err = util.ParseResp(body, &libraryAlbums); err != nil { return fmt.Errorf("failed to unmarshal library albums: %w", err) } @@ -244,9 +259,10 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { track.AlbumID = strconv.Itoa(libraryAlbum.ID) track.MainArtistID = strconv.Itoa(libraryAlbum.ArtistID) - + track.OriginalYear = libraryAlbum.ReleaseDate.Year() + c.cacheAlbum(track) - + slog.Info("album found in Lidarr library", "album", libraryAlbum.Title, "id", libraryAlbum.ID) if err := c.findTrackID(track); err != nil { return fmt.Errorf("failed to get track ID: %w", err) @@ -255,17 +271,22 @@ func (c *Lidarr) QueryTrack(track *models.Track) error { } func (c *Lidarr) GetTrack(track *models.Track) error { + + if track.Present { + return nil + } + slog.Info("downloading track", "title", track.Title, "artist", track.Artist, "album", track.Album, + "albumID", track.AlbumID, ) - if track.Present { - return nil - } if c.populateFromCache(track) { - return c.findTrackID(track) + if err := c.findTrackID(track); err == nil { + return nil // track exists + } } if track.AlbumID != "" { @@ -336,12 +357,13 @@ func (c *Lidarr) addNewAlbum(track *models.Track) error { } return err } - var album Album + var album LidarrAlbum if err = util.ParseResp(body, &album); err != nil { return fmt.Errorf("failed to unmarshal lidarr album: %w", err) } track.AlbumID = strconv.Itoa(album.ID) track.MainArtistID = strconv.Itoa(album.ArtistID) + track.OriginalYear = album.ReleaseDate.Year() c.cacheAlbum(track) if err := c.searchStatus(album.ID, 1); err != nil { return fmt.Errorf("album search failed: %w", err) @@ -371,14 +393,17 @@ func (c *Lidarr) getAlbumTracks(albumID string, artistID string) []LidarrTrack { body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) if err == nil { var tracks []LidarrTrack - if err := util.ParseResp(body, &tracks); err == nil { + err = util.ParseResp(body, &tracks) + if err == nil && len(tracks) != 0 { return tracks + } else if len(tracks) == 0 { + err = fmt.Errorf("no tracks loaded yet") } } - slog.Warn("failed loading album tracks", "attempt", attempt, "err", err) + slog.Warn("failed loading album tracks", "attempt", attempt, "albumID", albumID, "artistID", artistID, "ctx", err) if attempt < 3 { - time.Sleep(time.Second) + time.Sleep(time.Second * 15) } } return nil @@ -407,6 +432,9 @@ func (c *Lidarr) findTrackID(track *models.Track) error { track.Present = true slog.Info("track already present in Lidarr", "track", track.CleanTitle, "album", track.Album, "artist", track.MainArtist) } + track.MusicBrainzTrackID = t.ForeignRecordingID + track.MusicBrainzReleaseTrackID = t.ForeignTrackID + track.ID = strconv.Itoa(t.ID) return nil } @@ -446,12 +474,19 @@ func (c *Lidarr) searchStatus(AlbumID, count int) error { return c.searchStatus(AlbumID, count+1) } +const invalidAlbumID = "invalid" func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { albums := make(map[string][]*models.Track) for _, track := range tracks { - if track.ID == "" || track.AlbumID == "" || track.Present { + if track.ID == "" && track.AlbumID != invalidAlbumID { + // check the track cache for potentially missed tracks + if err := c.findTrackID(track); err != nil { + track.AlbumID = invalidAlbumID + } + } + if track.ID == "" || track.AlbumID == "" || track.AlbumID == invalidAlbumID || track.Present { continue } albums[track.AlbumID] = append(albums[track.AlbumID], track) @@ -473,29 +508,47 @@ func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatu // check for completed downloads and build map of tracks that might be in queue for albumID, albumTracks := range albums { - history ,err := c.getHistory(albumID) + history, err := c.getHistory(albumID) if err != nil { slog.Warn("[lidarr] failed to check download history", "err", err) } + + cache := c.AlbumCache[c.cacheKey(albumTracks[0])] for _, r := range history.Records { + if r.Data.ImportedPath == "" { + continue + } + historyTrackID := strconv.Itoa(r.TrackID) + + c.CacheMu.Lock() + cache.Files[r.TrackID] = AlbumFile{ + Path: r.Data.ImportedPath, + Track: r.Track} + c.CacheMu.Unlock() + for _, track := range albumTracks { if _, exists := statuses[track.ID]; exists { - continue - } - trackIDMatch := track.ID == strconv.Itoa(r.TrackID) - mbTrack := track.MusicBrainzTrackID - mbReleaseTrack := track.MusicBrainzReleaseTrackID - mbIDMatch := (mbTrack != "" && mbTrack == r.Track.ForeignTrackID) || (mbReleaseTrack != "" && mbReleaseTrack == r.Track.ForeignTrackID) - if (mbIDMatch || trackIDMatch) && r.Data.ImportedPath != "" { + continue + } + + trackIDMatch := track.ID == historyTrackID + + mbIDMatch := + (track.MusicBrainzTrackID != "" && + track.MusicBrainzTrackID == r.Track.ForeignRecordingID) || + (track.MusicBrainzReleaseTrackID != "" && + track.MusicBrainzReleaseTrackID == r.Track.ForeignTrackID) + + if (trackIDMatch || mbIDMatch) { statuses[track.ID] = FileStatus{ - ID: track.ID, - Filename: r.Data.ImportedPath, - State: "Succeeded", - BytesTransferred: 1, - BytesRemaining: 0, - PercentComplete: 100, - QueueID: "", + ID: track.ID, + Filename: r.Data.ImportedPath, + State: "Succeeded", + BytesTransferred: 1, + BytesRemaining: 0, + PercentComplete: 100, + QueueID: "", } } } @@ -505,7 +558,6 @@ func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatu if !ok { continue } - for _, track := range albumTracks { if _, e := statuses[track.ID]; e { continue @@ -520,7 +572,6 @@ func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatu QueueID: strconv.Itoa(record.ID), } } - } return statuses, nil } @@ -553,7 +604,7 @@ func (c *Lidarr) getQueue() ([]LidarrQueue, error) { // Queries Lidarr history for specified album func (c *Lidarr) getHistory(albumID string) (LidarrHistory, error) { - req := fmt.Sprintf("/api/v1/history?albumId=%s&pageSize=1111", albumID) + req := fmt.Sprintf("/api/v1/history?albumId=%s&includeTrack=true&pageSize=1111", albumID) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, c.Headers) if err != nil { return LidarrHistory{}, err @@ -608,7 +659,7 @@ func (c *Lidarr) getReleaseGroupId(track *models.Track) error { return fmt.Errorf("failed to lookup album: %w", err) } - var albums []Album + var albums []LidarrAlbum if err = util.ParseResp(body, &albums); err != nil { return fmt.Errorf("failed to unmarshal lookup response: %w", err) } @@ -623,12 +674,12 @@ func (c *Lidarr) getReleaseGroupId(track *models.Track) error { return nil } -func (c *Lidarr) filterAlbumSearch(albumName, mainArtist string, albums []Album) (string, string) { +func (c *Lidarr) filterAlbumSearch(albumName, mainArtist string, albums []LidarrAlbum) (string, string) { cleanAlbum := util.AlnumOnly(albumName) cleanArtist := util.AlnumOnly(mainArtist) bestScore := -1 - var bestAlbum *Album + var bestAlbum *LidarrAlbum for i := range albums { album := &albums[i] @@ -693,7 +744,7 @@ func (c *Lidarr) Cleanup(track models.Track, queueID string) error { return nil } if err := c.deleteDownload(queueID); err != nil { - slog.Info(fmt.Sprintf("[lidarr] failed to delete download: %v", err)) + slog.Info("[lidarr] failed to delete download", "err", err) } return nil } @@ -709,12 +760,16 @@ func (c *Lidarr) cacheAlbum(track *models.Track) { c.AlbumCache[key] = &AlbumMetadata{ ReleaseGroupMBID: track.MusicBrainzReleaseGroupID, ArtistMBID: track.MusicBrainzArtistID, + Name: track.Album, + MainArtist: track.MainArtist, AlbumID: track.AlbumID, ArtistID: track.MainArtistID, + ReleaseYear: track.OriginalYear, Tracks: sync.OnceValue(func() []LidarrTrack { return c.getAlbumTracks(track.AlbumID, track.MainArtistID) }), + Files: make(map[int]AlbumFile), } } // Checks if album is cached. Populates track fields if it is @@ -735,6 +790,85 @@ func (c *Lidarr) populateFromCache(track *models.Track) bool { } -func (c *Lidarr) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { +func (c *Lidarr) MoveDownload(srcDir, destDir, albumPath string, track *models.Track) error { + + key := c.cacheKey(track) + c.CacheMu.RLock() + album := c.AlbumCache[key] + if album == nil { + c.CacheMu.RUnlock() + return fmt.Errorf("album not cached") + } + if album.Moved { + c.CacheMu.RUnlock() + return nil + } + + albumFiles := maps.Clone(album.Files) + albumMeta := *album + c.CacheMu.RUnlock() + if len(albumFiles) == 0 { + return fmt.Errorf("no downloaded files found for album") + } + + slog.Info("moving album", "artist", track.MainArtist, "album", track.Album, "id", track.AlbumID) + var srcFile string + for _, file := range albumFiles { + relPath, err := filepath.Rel(c.RootFolder.Path, file.Path) + if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) { + slog.Warn("track path does not belong to Lidarr RootFolder, skipping", "trackPath", file.Path, "rootFolder", c.RootFolder.Path) + continue + } + + srcFile = filepath.Join(srcDir, relPath) + dstFile := filepath.Join(destDir, relPath) + + if c.Cfg.PathTemplate != "" { + albumTrack := c.addMetadatafromCache(albumMeta, file) + relativePath := buildTrackPath(c.Cfg.PathTemplate, &albumTrack) + track.File = filepath.Base(relativePath) + if track.File == "." || track.File == string(filepath.Separator) { + track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(file.Path) + relativePath = filepath.Dir(relativePath) + string(filepath.Separator) + track.File + slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) + } + dstFile = filepath.Join(destDir, relativePath) + } + if err := moveFile(srcFile, dstFile, c.Cfg.KeepPermissions); err != nil { + return fmt.Errorf("file move failed: %w", err) + } + albumDir := filepath.Dir(srcFile) + if filepath.Base(albumDir) != albumPath { + return fmt.Errorf("file path parent dir does not match previously gotten album path: filePath=%s albumPath=%s", srcFile, albumPath) + } + isEmpty, err := isDirEmpty(albumDir) + if err != nil { + return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) + } else if isEmpty { + if err = os.Remove(albumDir); err != nil { + return fmt.Errorf("failed to remove empty directory: %s", err.Error()) + } + } + } + c.CacheMu.Lock() + album.Moved = true + c.CacheMu.Unlock() + return nil +} + + +// Add metadata to album tracks from cache to use in path templating +func (c *Lidarr) addMetadatafromCache(album AlbumMetadata, file AlbumFile) models.Track { + + return models.Track { + + Title: file.Track.Title, + Album: album.Name, + MainArtist: album.MainArtist, + OriginalYear: album.ReleaseYear, + TrackNumber: file.Track.AbsoluteTrackNumber, + DiscNumber: file.Track.MediumNumber, + File: filepath.Base(file.Path), + } } \ No newline at end of file diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index f41d453c..780f499e 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -59,7 +59,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err for _, track := range tracks { - key := fmt.Sprintf("%s|%s", track.ID, track.File) + key := fmt.Sprintf("%s|%s", track.ID, track.CleanTitle) if track.Present || track.ID == "" || (progressMap[key] != nil && progressMap[key].Skipped) { continue @@ -77,13 +77,13 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err tracker := progressMap[key] if !exists { tracker.Counter++ - if tracker.Counter >= 2 { slog.Info("[monitor] track not found in queue after retries, skipping", "service", monCfg.Service,"track title", track.CleanTitle, "track artist", track.MainArtist) tracker.Skipped = true } continue - } + } + tracker.Counter = 0 monitoredTime := currentTime.Sub(tracker.LastUpdated) if (fileStatus.BytesRemaining == 0 && fileStatus.BytesTransferred != 0) || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { @@ -94,7 +94,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err track.File, filePath = parsePath(track.File) if monCfg.MigrateDownload { if err = m.MoveDownload(monCfg.FromDir, monCfg.ToDir, filePath, track); err != nil { - slog.Error("error while moving file", "err", err.Error()) + slog.Error("error while moving file", "err", err) } else { slog.Info("track moved successfully", "service", monCfg.Service) } @@ -109,11 +109,11 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } else if fileStatus.BytesTransferred > tracker.LastBytesTransferred { tracker.LastBytesTransferred = fileStatus.BytesTransferred tracker.LastUpdated = currentTime - slog.Info("[monitor] progress updated", "service", monCfg.Service, "file", track.File, "bytes transferred", fileStatus.BytesTransferred) + slog.Info("[monitor] progress updated", "service", monCfg.Service, "title", track.CleanTitle, "bytes transferred", fileStatus.BytesTransferred) continue } else if monitoredTime > monCfg.MonitorDuration || fileStatus.State == "Errored" { - slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "file", track.File, "state", fileStatus.State, "duration", monitoredTime,) + slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "title", track.CleanTitle, "state", fileStatus.State, "duration", monitoredTime,) tracker.Skipped = true if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) @@ -133,10 +133,10 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err // Checks if all tracks are processed (either downloaded or skipped) func tracksProcessed(tracks []*models.Track, progressMap map[string]*DownloadMonitor) bool { for _, track := range tracks { - key := fmt.Sprintf("%s|%s", track.ID, track.File) + key := fmt.Sprintf("%s|%s", track.ID, track.CleanTitle) tracker, exists := progressMap[key] if !track.Present && exists && !tracker.Skipped { - slog.Info("[monitor] track download still in progress", "title", track.CleanTitle, "artist", track.MainArtist, "file", track.File) + slog.Info("[monitor] track download still in progress", "title", track.CleanTitle, "artist", track.MainArtist) return false } } diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index 94093540..d3770db4 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -15,7 +15,6 @@ import ( "strings" "time" "os" - "io" ) type Search struct { @@ -534,42 +533,9 @@ func (c *Slskd) MoveDownload(srcDir, destDir, trackPath string, track *models.Tr dstFile = filepath.Join(destDir, track.File) } - if err = os.MkdirAll(filepath.Dir(dstFile), os.ModePerm); err != nil { - return fmt.Errorf("couldn't make destination directory: %s", err.Error()) - } - - out, err := os.Create(dstFile) - if err != nil { - return fmt.Errorf("couldn't create destination file: %s", err.Error()) - } - - defer func() { - if cerr := out.Close(); cerr != nil { - slog.Error(fmt.Sprintf("failed to close destination file: %s", cerr.Error())) - } - }() - - if _, err = io.Copy(out, in); err != nil { - return fmt.Errorf("copy failed: %s", err.Error()) - } - - if err = out.Sync(); err != nil { - return fmt.Errorf("sync failed: %s", err.Error()) - } - - if c.Cfg.KeepPermissions { - info, err := os.Stat(srcFile) - if err != nil { - return fmt.Errorf("stat error: %s", err.Error()) + if err := moveFile(srcFile, dstFile, c.Cfg.KeepPermissions); err != nil { + return fmt.Errorf("file move failed: %w", err) } - if err = os.Chmod(dstFile, info.Mode()); err != nil { - return fmt.Errorf("chmod failed: %s", err.Error()) - } - } - - if err = os.Remove(srcFile); err != nil { - return fmt.Errorf("failed to delete original file: %s", err.Error()) - } isEmpty, err := isDirEmpty(trackDir) if err != nil { From b474a5085e045c6bd826926798b01a8a35a5a188 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 23 Aug 2026 18:58:03 +0300 Subject: [PATCH 31/39] fix linter --- src/downloader/downloader.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 52301646..f4910a7f 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -288,7 +288,11 @@ func moveFile(srcFile, dstFile string, keepPermissions bool) error { if err != nil { return fmt.Errorf("couldn't open source file: %w", err) } - defer in.Close() + defer func() { + if err := in.Close(); err != nil { + slog.Warn("failed to close source file", "err", err) + } + }() if err := os.MkdirAll(filepath.Dir(dstFile), 0755); err != nil { return fmt.Errorf("couldn't create directory for file: %w", err) } @@ -298,12 +302,16 @@ func moveFile(srcFile, dstFile string, keepPermissions bool) error { } if _, err := io.Copy(out, in); err != nil { - out.Close() + if closeErr := out.Close(); closeErr != nil { + slog.Warn("failed to close destination file", "err", closeErr) + } return fmt.Errorf("copy failed: %w", err) } if err := out.Sync(); err != nil { - out.Close() + if closeErr := out.Close(); closeErr != nil { + slog.Warn("failed to close destination file", "err", closeErr) + } return fmt.Errorf("sync failed: %w", err) } From b58f99e8439bdb30e9a9e2e3a95793b0f4f2ef34 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 25 Aug 2026 18:18:45 +0300 Subject: [PATCH 32/39] fix path templating in lidarr --- src/downloader/lidarr.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 587d6067..bee11e68 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -284,7 +284,7 @@ func (c *Lidarr) GetTrack(track *models.Track) error { ) if c.populateFromCache(track) { - if err := c.findTrackID(track); err == nil { + if err := c.findTrackID(track); err == nil && track.Present { return nil // track exists } } @@ -833,7 +833,7 @@ func (c *Lidarr) MoveDownload(srcDir, destDir, albumPath string, track *models.T slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) } dstFile = filepath.Join(destDir, relativePath) - } + } if err := moveFile(srcFile, dstFile, c.Cfg.KeepPermissions); err != nil { return fmt.Errorf("file move failed: %w", err) } @@ -862,8 +862,7 @@ func (c *Lidarr) MoveDownload(srcDir, destDir, albumPath string, track *models.T func (c *Lidarr) addMetadatafromCache(album AlbumMetadata, file AlbumFile) models.Track { return models.Track { - - Title: file.Track.Title, + CleanTitle: file.Track.Title, Album: album.Name, MainArtist: album.MainArtist, OriginalYear: album.ReleaseYear, From ba3aa5c9deffa0fe94e89313c9b5043825f77606 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 25 Aug 2026 20:08:51 +0300 Subject: [PATCH 33/39] add max time to monitor track/album --- src/config/config.go | 2 ++ src/downloader/lidarr.go | 1 + src/downloader/monitor.go | 33 ++++++++++++++++++++++++++++++--- src/downloader/slskd.go | 2 ++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 8cf49fab..87b78c6a 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -154,6 +154,7 @@ type Slskd struct { type SlskdMon struct { Interval int `env:"SLSKD_MONITOR_INTERVAL" env-default:"1"` // in minutes Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` // in minutes + MaxDuration int `env:"SLSKD_MONITOR_MAX_DURATION" env-default:"120"` // in minutes } type Lidarr struct { @@ -173,6 +174,7 @@ type Lidarr struct { type LidarrMon struct { Interval int `env:"LIDARR_MONITOR_INTERVAL" env-default:"1"` // in minutes Duration int `env:"LIDARR_MONITOR_DURATION" env-default:"20"` // in minutes + MaxDuration int `env:"LIDARR_MONITOR_MAX_DURATION" env-default:"120"` // in minutes } type DiscoveryConfig struct { diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index bee11e68..c698011d 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -220,6 +220,7 @@ 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, + MaxDuration: time.Duration(c.Cfg.MonitorConfig.MaxDuration) * time.Minute, MigrateDownload: c.Cfg.MigrateDL, ToDir: c.DownloadDir, FromDir: c.Cfg.LidarrDir, diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 780f499e..b5177d4b 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -19,6 +19,7 @@ type Monitor interface { type MonitorConfig struct { CheckInterval time.Duration MonitorDuration time.Duration + MaxDuration time.Duration MigrateDownload bool FromDir string ToDir string @@ -46,6 +47,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } ticker := time.NewTicker(monCfg.CheckInterval) + defer ticker.Stop() for range ticker.C { @@ -71,6 +73,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err LastBytesTransferred: 0, Counter: 0, LastUpdated: currentTime, + StartedAt: currentTime, } } fileStatus, exists := statuses[track.ID] @@ -84,7 +87,9 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err continue } tracker.Counter = 0 - monitoredTime := currentTime.Sub(tracker.LastUpdated) + + stallTime := currentTime.Sub(tracker.LastUpdated) + monitoredTime := currentTime.Sub(tracker.StartedAt) if (fileStatus.BytesRemaining == 0 && fileStatus.BytesTransferred != 0) || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { track.File = fileStatus.Filename @@ -112,8 +117,30 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err slog.Info("[monitor] progress updated", "service", monCfg.Service, "title", track.CleanTitle, "bytes transferred", fileStatus.BytesTransferred) continue - } else if monitoredTime > monCfg.MonitorDuration || fileStatus.State == "Errored" { - slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "title", track.CleanTitle, "state", fileStatus.State, "duration", monitoredTime,) + } else if fileStatus.State == "Errored" || + stallTime > monCfg.MonitorDuration || + monitoredTime > monCfg.MaxDuration { + + switch { + case fileStatus.State == "Errored": + slog.Info("[monitor] download errored", + "service", monCfg.Service, + "title", track.CleanTitle, + ) + case stallTime > monCfg.MonitorDuration: + slog.Info("[monitor] download stalled", + "service", monCfg.Service, + "title", track.CleanTitle, + "duration", stallTime, + ) + default: + slog.Info("[monitor] maximum monitor time exceeded", + "service", monCfg.Service, + "title", track.CleanTitle, + "duration", monitoredTime, + ) + } + tracker.Skipped = true if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index d3770db4..b53945bc 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -93,6 +93,7 @@ type DownloadMonitor struct { PlaceInQueue int Skipped bool LastUpdated time.Time + StartedAt time.Time } type Slskd struct { @@ -124,6 +125,7 @@ func (c *Slskd) GetConf() (MonitorConfig, error) { return MonitorConfig{ CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute, MonitorDuration: time.Duration(c.Cfg.MonitorConfig.Duration) * time.Minute, + MaxDuration: time.Duration(c.Cfg.MonitorConfig.MaxDuration) * time.Minute, MigrateDownload: c.Cfg.MigrateDL, ToDir: c.DownloadDir, FromDir: c.Cfg.SlskdDir, From 64c8c1548fe4d67781c2824ddcfd2f1b08c4971b Mon Sep 17 00:00:00 2001 From: LumePart Date: Thu, 27 Aug 2026 23:03:53 +0300 Subject: [PATCH 34/39] add lidarr config options to UI --- src/config/config.go | 2 +- src/web/backend/defs/defs.go | 6 +-- src/web/frontend/src/components/Wizard.jsx | 61 +++++++++++----------- 3 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 87b78c6a..4f0a1f19 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -160,7 +160,7 @@ type SlskdMon struct { type Lidarr struct { APIKey string `env:"LIDARR_API_KEY"` URL string `env:"LIDARR_URL"` - Retry int `env:"LIDARR_RETRY" env-default:"8"` // Number of times to check search status before skipping the track + Retry int `env:"LIDARR_RETRY" env-default:"8"` // Number of times to check album/track 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"` diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index e25dd555..6a3c289d 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -193,7 +193,7 @@ var AllConfigKeys = []string{ "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", "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", + "SLSKD_URL", "SLSKD_API_KEY", "SLSKD_MIGRATE_DOWNLOADS", + "LIDARR_URL", "LIDARR_API_KEY", "LIDARR_MIGRATE_DOWNLOADS", "LIDARR_ROOT_FOLDER", + "WIZARD_COMPLETE", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", } diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 79fd6ccd..5f0b61c8 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -472,7 +472,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { const { downloadDir, useSubdirectory, - migrateDownloads, + slskdMigrateDownloads, dlServices, youtubeApiKey, trackExtension, @@ -481,6 +481,8 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { slskdApiKey, lidarrUrl, lidarrApiKey, + lidarrMigrateDownloads, + lidarrRootFolder, extensions, } = fields; const isLocked = (key) => envSources[key] === "env"; @@ -530,7 +532,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} /> + hint="Custom download directory. Leave blank to use the default (recommended when running in a container)."> setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} placeholder="/data/" /> @@ -582,17 +584,17 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { configured in your slskd instance.

setField("migrateDownloads", v)} - disabled={isLocked("MIGRATE_DOWNLOADS")} + checked={slskdMigrateDownloads} + onChange={(v) => setField("slskdMigrateDownloads", v)} + disabled={isLocked("SLSKD_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 */} - +
+ hint="Custom download directory. Leave blank to use the default (recommended when running in a container)."> setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} placeholder="/data/" /> @@ -615,47 +617,42 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { checked={dlServices.lidarr} onChange={v => setField('dlServices', { ...dlServices, lidarr: v })} name="Lidarr" - desc="Downloads using Lidarr's config · requires a running Lidarr instance" + desc="Requests albums via Lidarr · requires a running Lidarr instance" />
setField('lidarrUrl', e.target.value)} - placeholder="e.g. http://192.168.1.100:5030" disabled={isLocked('LIDARR_URL')} /> + placeholder="e.g. http://192.168.1.100:8686" 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')} /> + + setField('lidarrRootFolder', e.target.value)} + placeholder="Music" autoComplete="off" spellCheck={false} disabled={isLocked('LIDARR_ROOT_FOLDER')} /> - {/* 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. + By default, lidarr saves tracks to whichever download path is configured in your Root Folder.

setField('migrateDownloads', v)} - disabled={isLocked('MIGRATE_DOWNLOADS')} + checked={lidarrMigrateDownloads} + onChange={v => setField('lidarrMigrateDownloads', v)} + disabled={isLocked('LIDARR_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 */} - + {/* Only show download dir here when previous downloaders aren't enabled — otherwise it lives in the YouTube or slskd section */} +
+

+ Alternative: Create a dedicated Root Folder for Explo in Lidarr and define it above. Lidarr will handle organizing downloads itself, allowing you to leave "Move completed downloads" disabled. +

+ hint="Custom download directory. Leave blank to use the default (recommended when running in a container)."> setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} placeholder="/data/" /> @@ -721,7 +718,6 @@ export default function Wizard({ // Step 3 downloadDir: config.DOWNLOAD_DIR || "", useSubdirectory: config.USE_SUBDIRECTORY !== "false", - migrateDownloads: config.MIGRATE_DOWNLOADS === "true", dlServices: { youtube: s.includes("youtube"), slskd: s.includes("slskd"), @@ -732,8 +728,11 @@ export default function Wizard({ filterList: config.FILTER_LIST || "", slskdUrl: config.SLSKD_URL || "", slskdApiKey: config.SLSKD_API_KEY || "", + slskdMigrateDownloads: config.SLSKD_MIGRATE_DOWNLOADS === "true", lidarrUrl: config.LIDARR_URL || '', lidarrApiKey: config.LIDARR_API_KEY || '', + lidarrRootFolder: config.LIDARR_ROOT_FOLDER || "", + lidarrMigrateDownloads: config.LIDARR_MIGRATE_DOWNLOADS === "true", extensions: config.EXTENSIONS || "", adminAuthMethod: config.ADMIN_AUTH_METHOD || "password", adminApiKey: config.ADMIN_API_KEY || "", @@ -813,7 +812,7 @@ export default function Wizard({ await wizardStep3({ download_dir: fields.downloadDir, use_subdirectory: fields.useSubdirectory, - migrate_downloads: fields.migrateDownloads, + slskd_migrate_downloads: fields.slskdMigrateDownloads, download_services: services, youtube_api_key: fields.youtubeApiKey, track_extension: fields.trackExtension, @@ -822,6 +821,8 @@ export default function Wizard({ slskd_api_key: fields.slskdApiKey, lidarr_url: fields.lidarrUrl, lidarr_api_key: fields.lidarrApiKey, + lidarr_root_folder: fields.lidarrRootFolder, + lidarr_migrate_downloads: fields.lidarrMigrateDownloads, extensions: fields.extensions, }); onComplete(); From abe52fda0e768872363908fea5b5c31e15645010 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 28 Aug 2026 18:28:19 +0300 Subject: [PATCH 35/39] propagate file monitor changes to slskd --- src/downloader/slskd.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index b53945bc..ac5b57af 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -164,7 +164,7 @@ func (c *Slskd) QueryTrack(track *models.Track) error { if err != nil { cleanup() - return err + return fmt.Errorf("%w: %s", err, trackDetails) } if !completed { @@ -401,13 +401,15 @@ func (c *Slskd) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus for _, dir := range status.Directories { for _, file := range dir.Files { if string(file.Name) == track.File { - fileStatuses[track.File] = FileStatus{ + fileStatuses[track.ID] = FileStatus{ ID: file.ID, Size: file.Size, State: normalize(file.State), + Filename: file.Name, BytesTransferred: file.BytesTransferred, BytesRemaining: file.BytesRemaining, PercentComplete: file.PercentComplete, + QueueID: file.ID, } } } @@ -518,7 +520,6 @@ func (c *Slskd) MoveDownload(srcDir, destDir, trackPath string, track *models.Tr }() var dstFile string - if c.Cfg.PathTemplate != "" { relativePath := buildTrackPath(c.Cfg.PathTemplate, track) track.File = filepath.Base(relativePath) From 4fe45eb69c45cdf2ceeda2a5847cd366b5ff465d Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 28 Aug 2026 19:08:46 +0300 Subject: [PATCH 36/39] helper function to move track --- src/downloader/downloader.go | 42 ++++++++++++++++++++++++++++++++ src/downloader/lidarr.go | 34 +++++--------------------- src/downloader/slskd.go | 47 +++--------------------------------- 3 files changed, 52 insertions(+), 71 deletions(-) diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index f4910a7f..1c6048e7 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -283,6 +283,48 @@ func tempAudioFile(path string) string { return strings.TrimSuffix(path, ext) + ".tmp" + ext } + +func moveTrack(srcFile, destDir string, track *models.Track, pathTemplate string, keepPerms bool) error { + dstFile := filepath.Join(destDir, filepath.Base(srcFile)) + + if pathTemplate != "" { + relativePath := buildTrackPath(pathTemplate, track) + track.File = filepath.Base(relativePath) + + if track.File == "." || track.File == string(filepath.Separator) { + track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(srcFile) + relativePath = filepath.Join(filepath.Dir(relativePath), track.File) + slog.Warn("invalid path template result", + "track", track.Title, + "artist", track.Artist, + "filename", track.File) + } + + dstFile = filepath.Join(destDir, relativePath) + } else { + if err := os.MkdirAll(destDir, os.ModePerm); err != nil { + return err + } + dstFile = filepath.Join(destDir, track.File) + } + + if err := moveFile(srcFile, dstFile, keepPerms); err != nil { + return err + } + + srcDir := filepath.Dir(srcFile) + isEmpty, err := isDirEmpty(srcDir) + if err != nil { + return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) + } else if isEmpty { + if err = os.Remove(srcDir); err != nil { + return fmt.Errorf("failed to remove empty directory: %s", err.Error()) + } + } + + return nil +} + func moveFile(srcFile, dstFile string, keepPermissions bool) error { in, err := os.Open(srcFile) if err != nil { diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index c698011d..5d98a8d1 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -822,34 +822,12 @@ func (c *Lidarr) MoveDownload(srcDir, destDir, albumPath string, track *models.T } srcFile = filepath.Join(srcDir, relPath) - dstFile := filepath.Join(destDir, relPath) - - if c.Cfg.PathTemplate != "" { - albumTrack := c.addMetadatafromCache(albumMeta, file) - relativePath := buildTrackPath(c.Cfg.PathTemplate, &albumTrack) - track.File = filepath.Base(relativePath) - if track.File == "." || track.File == string(filepath.Separator) { - track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(file.Path) - relativePath = filepath.Dir(relativePath) + string(filepath.Separator) + track.File - slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) - } - dstFile = filepath.Join(destDir, relativePath) - } - if err := moveFile(srcFile, dstFile, c.Cfg.KeepPermissions); err != nil { - return fmt.Errorf("file move failed: %w", err) - } - albumDir := filepath.Dir(srcFile) - if filepath.Base(albumDir) != albumPath { - return fmt.Errorf("file path parent dir does not match previously gotten album path: filePath=%s albumPath=%s", srcFile, albumPath) - } - isEmpty, err := isDirEmpty(albumDir) - if err != nil { - return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) - } else if isEmpty { - if err = os.Remove(albumDir); err != nil { - return fmt.Errorf("failed to remove empty directory: %s", err.Error()) - } - } + + albumTrack := c.addMetadatafromCache(albumMeta, file) + + if err := moveTrack(srcFile, destDir, &albumTrack, c.Cfg.PathTemplate, c.Cfg.KeepPermissions); err != nil { + return fmt.Errorf("failed to move track: %w", err) + } } c.CacheMu.Lock() album.Moved = true diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index ac5b57af..38e6e1e2 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -14,7 +14,6 @@ import ( "slices" "strings" "time" - "os" ) type Search struct { @@ -496,58 +495,20 @@ func normalize(state string) string{ func (c *Slskd) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { trackDir := filepath.Join(srcDir, trackPath) - srcFile := filepath.Join(trackDir, track.File) if c.Cfg.RenameTrack { // Rename file to {title}-{artist} format track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) } + srcFile := filepath.Join(trackDir, track.File) if c.Cfg.OverwriteMetadata { metadata := util.BuildffmpegMetadata(*track) if err := overwriteMetadata(metadata, srcFile); err != nil { slog.Warn("problem overwriting metadata", "msg", err.Error()) } } - - in, err := os.Open(srcFile) - if err != nil { - return fmt.Errorf("couldn't open source file: %s", err.Error()) - } - - defer func() { - if cerr := in.Close(); cerr != nil { - slog.Error(fmt.Sprintf("failed to close source file: %s", cerr.Error())) - } - }() - - var dstFile string - if c.Cfg.PathTemplate != "" { - relativePath := buildTrackPath(c.Cfg.PathTemplate, track) - track.File = filepath.Base(relativePath) - if track.File == "." || track.File == string(filepath.Separator) { - track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) - relativePath = filepath.Dir(relativePath) + string(filepath.Separator) + track.File - slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) - } - dstFile = filepath.Join(destDir, relativePath) - } else { - if err = os.MkdirAll(destDir, os.ModePerm); err != nil { - return fmt.Errorf("couldn't make download directory: %s", err.Error()) - } - - dstFile = filepath.Join(destDir, track.File) - } - if err := moveFile(srcFile, dstFile, c.Cfg.KeepPermissions); err != nil { - return fmt.Errorf("file move failed: %w", err) - } - - isEmpty, err := isDirEmpty(trackDir) - if err != nil { - return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) - } else if isEmpty { - if err = os.Remove(trackDir); err != nil { - return fmt.Errorf("failed to remove empty directory: %s", err.Error()) - } - } + if err := moveTrack(srcFile, destDir, track, c.Cfg.PathTemplate, c.Cfg.KeepPermissions); err != nil { + return fmt.Errorf("failed to move track: %w", err) + } return nil } \ No newline at end of file From efafc1b629cc9850ecaa22de52801e02f86037be Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 28 Aug 2026 20:38:31 +0300 Subject: [PATCH 37/39] improve logging on function --- src/downloader/lidarr.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 5d98a8d1..0e5640b4 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -390,20 +390,21 @@ func (c *Lidarr) triggerAlbumSearch(albumID int) error { func (c *Lidarr) getAlbumTracks(albumID string, artistID string) []LidarrTrack { queryURL := fmt.Sprintf("%s/api/v1/track?albumId=%s&artistId=%s", c.Cfg.URL, albumID, artistID) - for attempt := 1; attempt <= 3; attempt++ { + for attempt := 1; attempt <= c.Cfg.Retry; attempt++ { body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) if err == nil { var tracks []LidarrTrack - err = util.ParseResp(body, &tracks) - if err == nil && len(tracks) != 0 { + if err = util.ParseResp(body, &tracks); err == nil && len(tracks) != 0 { return tracks } else if len(tracks) == 0 { - err = fmt.Errorf("no tracks loaded yet") + err = fmt.Errorf("no tracks loaded after retries") } } - slog.Warn("failed loading album tracks", "attempt", attempt, "albumID", albumID, "artistID", artistID, "ctx", err) - - if attempt < 3 { + slog.Debug("no album tracks loaded", "albumID", albumID, "artistID", artistID, "attempt", attempt, "maxAttempts", c.Cfg.Retry) + if attempt == c.Cfg.Retry { + slog.Error("failed loading album tracks", "albumID", albumID, "artistID", artistID, "err", err) + } + if attempt < c.Cfg.Retry { time.Sleep(time.Second * 15) } } From f3e524a26707dec73a7563accd6faca0f6c1bed9 Mon Sep 17 00:00:00 2001 From: LumePart Date: Fri, 28 Aug 2026 20:42:22 +0300 Subject: [PATCH 38/39] linter --- src/downloader/downloader.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 1c6048e7..47921656 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -285,8 +285,7 @@ func tempAudioFile(path string) string { func moveTrack(srcFile, destDir string, track *models.Track, pathTemplate string, keepPerms bool) error { - dstFile := filepath.Join(destDir, filepath.Base(srcFile)) - + var dstFile string if pathTemplate != "" { relativePath := buildTrackPath(pathTemplate, track) track.File = filepath.Base(relativePath) From 12fc2fc0e015f5e98dd58ec6b54e437f731cb998 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Fri, 28 Aug 2026 15:43:32 -0400 Subject: [PATCH 39/39] Remove branch builds --- .github/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb67a307..f658412b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,6 @@ 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: @@ -134,7 +133,7 @@ jobs: build-dev: name: Build/publish dev image runs-on: ubuntu-latest - if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/feat/add-lidarr-download-config' + if: github.ref == 'refs/heads/dev' permissions: contents: read packages: write