From f3db69ca6dc568b79e51ea9d836476f6b683fcc6 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 29 Apr 2025 20:16:28 -0400 Subject: [PATCH 01/14] 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 512c6a93..0e36e65c 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -99,6 +99,7 @@ type DownloadConfig struct { Youtube Youtube YoutubeMusic YoutubeMusic Slskd Slskd + Lidarr Lidarr ExcludeLocal bool DownloadLimiter int `env:"DOWNLOAD_LIMITER" env-default:"1"` // rate limit download operations OverwriteMetadata bool `env:"OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads @@ -134,6 +135,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 25c196af..26adfd61 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 3353afc0acc2a33c34f0eddd3e0688b99b0722b8 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Sun, 4 May 2025 12:29:46 -0400 Subject: [PATCH 02/14] 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 11e10e3249bee5c798e7a971c7781f22c09ecc9d Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Mon, 5 May 2025 17:20:09 -0400 Subject: [PATCH 03/14] 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 0e36e65c..90727733 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -88,6 +88,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"` @@ -135,14 +151,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 26adfd61..6195a255 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 e616b0d8f6a858fc6d2534e2a4d20f75c6399a97 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 22 Jul 2025 02:57:15 -0400 Subject: [PATCH 04/14] 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 90727733..7c18036c 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -88,20 +88,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 c7f3509d..338b8743 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -37,7 +37,7 @@ type Metadata struct { Artist struct { ArtistCreditID int `json:"artist_credit_id"` Artists []struct { - ArtistMbid string `json:"artist_mbid"` + MusicBrainzArtistID string `json:"artist_mbid"` BeginYear int `json:"begin_year"` EndYear int `json:"end_year,omitempty"` JoinPhrase string `json:"join_phrase"` @@ -121,7 +121,7 @@ type Exploration struct { AdditionalMetadata struct { Artists []struct { ArtistCreditName string `json:"artist_credit_name"` - ArtistMbid string `json:"artist_mbid"` + MusicBrainzArtistID string `json:"artist_mbid"` JoinPhrase string `json:"join_phrase"` } `json:"artists"` CaaID int64 `json:"caa_id"` @@ -360,7 +360,7 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T MusicBrainzTrackID: mbTrackID, MusicBrainzAlbumID: rel.CaaReleaseMbid, MusicBrainzReleaseGroupID: rel.ReleaseGroupMbid, - MusicBrainzArtistID: recArtists[0].ArtistMbid, + MusicBrainzArtistID: recArtists[0].MusicBrainzArtistID, }) } @@ -409,7 +409,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( mainArtist := recording.Artist.Name mainArtistID := "" if len(recording.Artist.Artists) > 0 { - mainArtistID = recording.Artist.Artists[0].ArtistMbid + mainArtistID = recording.Artist.Artists[0].MusicBrainzArtistID } recArtists := recording.Artist.Artists @@ -563,7 +563,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( if len(recArtists) == 0 { return "" } - return recArtists[0].ArtistMbid + return recArtists[0].MusicBrainzArtistID }(), } } @@ -705,9 +705,9 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri recordingMBID = parts[len(parts)-1] } - artistMBID := "" + MusicBrainzArtistID := "" if len(trackMeta.Artists) > 0 { - artistMBID = trackMeta.Artists[0].ArtistMbid + MusicBrainzArtistID = trackMeta.Artists[0].MusicBrainzArtistID } tracks = append(tracks, &models.Track{ @@ -719,7 +719,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri Duration: track.Duration, CoverURL: coverURL, MusicBrainzTrackID: recordingMBID, - MusicBrainzArtistID: artistMBID, + MusicBrainzArtistID: MusicBrainzArtistID, MusicBrainzAlbumID: trackMeta.CaaReleaseMbid, }) } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 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 6195a255..25c196af 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 cb7ec3f51d9635dc78a7689dbae3c05f6521c190 Mon Sep 17 00:00:00 2001 From: Avery Date: Thu, 4 Jun 2026 11:24:16 -0400 Subject: [PATCH 05/14] 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 7c18036c..7a490fde 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -88,23 +88,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"` @@ -169,10 +152,27 @@ type SlskdMon struct { Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` } +type Lidarr struct { + APIKey string `env:"LIDARR_API_KEY"` + Retry int `env:"LIDARR_RETRY" env-default:"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"` @@ -242,6 +242,7 @@ func (cfg *Config) CommonFixes() { cfg.DownloadCfg.Youtube.CoversDir = filepath.Join(filepath.Dir(cfg.ServerCfg.WebDataDir), "cache", "covers") cfg.ClientCfg.URL = fixBaseURL(cfg.ClientCfg.URL) cfg.DownloadCfg.Slskd.URL = fixBaseURL(cfg.DownloadCfg.Slskd.URL) + cfg.DownloadCfg.Lidarr.URL = fixBaseURL(cfg.DownloadCfg.Lidarr.URL) cfg.NormalizeDir() } @@ -250,6 +251,7 @@ func (cfg *Config) NormalizeDir() { cfg.ClientCfg.PlaylistDir = fixDir(cfg.ClientCfg.PlaylistDir) } cfg.DownloadCfg.Slskd.SlskdDir = fixDir(cfg.DownloadCfg.Slskd.SlskdDir) + cfg.DownloadCfg.Lidarr.LidarrDir = fixDir(cfg.DownloadCfg.Lidarr.LidarrDir) cfg.DownloadCfg.DownloadDir = fixDir(cfg.DownloadCfg.DownloadDir) } diff --git a/src/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 bcd2c3c9..0cacf82e 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() @@ -156,6 +176,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. @@ -168,4 +201,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 f2b0ebd2501e76e1f420ef8d71b54befd98efc6f Mon Sep 17 00:00:00 2001 From: Avery Date: Fri, 5 Jun 2026 14:27:43 -0400 Subject: [PATCH 06/14] 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 0cacf82e..bcd2c3c9 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() @@ -176,19 +156,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. @@ -201,913 +168,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 2e4cb79d6cb95294b8f36eaf18fd758d224440be Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 14:45:11 +0300 Subject: [PATCH 07/14] 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 79d10dee..2760cf10 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++ @@ -96,7 +97,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } delete(progressMap, key) successDownloads += 1 - if err = m.Cleanup(*track, fileStatus.ID); err != nil { + if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) } continue @@ -110,7 +111,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } else if currentTime.Sub(tracker.LastUpdated) > monCfg.MonitorDuration || fileStatus.State == "Errored" { slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "file", track.File, "duration", monCfg.MonitorDuration) tracker.Skipped = true - if err = m.Cleanup(*track, fileStatus.ID); err != nil { + if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) } continue From 9c9b3566dd6d2caf3901c2b7bf88936f319c8512 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 14:47:19 +0300 Subject: [PATCH 08/14] 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 3047fcedd6de67f201de0b1f5902937db766ffa3 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 14:55:45 +0300 Subject: [PATCH 09/14] 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 0cf43b64294caa889d93a0ec8ab1b40bd78b8989 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 15:01:27 +0300 Subject: [PATCH 10/14] 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 7a490fde..7f812b95 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -155,9 +155,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 @@ -165,8 +164,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 e1d3c843cce6abff53de8cf623310a3f83e1a084 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 15:10:51 +0300 Subject: [PATCH 11/14] 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 2760cf10..50f7b5e2 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -83,7 +83,8 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err continue } - if fileStatus.BytesRemaining == 0 || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { + if (fileStatus.BytesRemaining == 0 && fileStatus.BytesTransferred != 0) || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { + track.File = fileStatus.Filename track.Present = true slog.Info("[monitor] file downloaded successfully", "service", monCfg.Service, "file", track.File) var path string From 0a728ccc2feb1863e90e8f6f528c12cce1576bcf Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 17:57:18 +0300 Subject: [PATCH 12/14] 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 7f812b95..54145988 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -154,7 +154,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 50f7b5e2..a9fd1858 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 bb7e0413608181dfc8645f097a511434bc915144 Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 19:35:10 +0300 Subject: [PATCH 13/14] 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 a9fd1858..332b6a1d 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 82b6c52cbe43d0b47d39ef504e353f380a32803f Mon Sep 17 00:00:00 2001 From: LumePart Date: Tue, 7 Jul 2026 19:48:09 +0300 Subject: [PATCH 14/14] 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 54145988..48f58719 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -159,6 +159,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 {