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/go.sum b/go.sum index 363c6d62..3d6ef731 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= diff --git a/sample.env b/sample.env index 40cf2dae..97c6117b 100644 --- a/sample.env +++ b/sample.env @@ -93,6 +93,17 @@ YOUTUBE_API_KEY= # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella +# === Lidarr Configuration === + +# LIDARR_API_KEY= +# LIDARR_RETRY= +# LIDARR_DL_ATTEMPTS= +# LIDARR_DIR= +# MIGRATE_DOWNLOADS= +# LIDARR_TIMEOUT= +# LIDARR_SCHEME= +# LIDARR_URL= + # === Metadata / Formatting === # Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true) diff --git a/src/config/config.go b/src/config/config.go index 3b336681..abda1827 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -94,6 +94,7 @@ type DownloadConfig struct { Youtube Youtube YoutubeMusic YoutubeMusic Slskd Slskd + Lidarr Lidarr ExcludeLocal bool KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format @@ -143,18 +144,35 @@ type SlskdMon struct { Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` } +type Lidarr struct { + APIKey string `env:"LIDARR_API_KEY"` + Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"` + MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir + Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"` + URL string `env:"LIDARR_URL"` + Filters Filters + MonitorConfig LidarrMon +} + +type LidarrMon struct { + Interval time.Duration `env:"LIDARR_MONITOR_INTERVAL" env-default:"1m"` + Duration time.Duration `env:"LIDARR_MONITOR_DURATION" env-default:"15m"` +} + type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` ArtistBlacklist []string `env:"ARTIST_BLACKLIST"` - Listenbrainz Listenbrainz + Listenbrainz Listenbrainz } type Listenbrainz struct { - Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` - User string `env:"LISTENBRAINZ_USER"` - ImportPlaylist string - SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` - CoverArtSize string `env:"COVER_ART_SIZE" env-default:"250"` - EnrichTrackMetadata bool `env:"ENRICH_TRACK_METADATA" env-default:"false"` + Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` + User string `env:"LISTENBRAINZ_USER"` + ImportPlaylist string + SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` + CoverArtSize string `env:"COVER_ART_SIZE" env-default:"250"` + EnrichTrackMetadata bool `env:"ENRICH_TRACK_METADATA" env-default:"false"` } type NotifyConfig struct { @@ -215,6 +233,7 @@ func (cfg *Config) CommonFixes() { cfg.DownloadCfg.Youtube.CoversDir = filepath.Join(filepath.Dir(cfg.Flags.CfgPath), "cache", "covers") cfg.ClientCfg.URL = fixBaseURL(cfg.ClientCfg.URL) cfg.DownloadCfg.Slskd.URL = fixBaseURL(cfg.DownloadCfg.Slskd.URL) + cfg.DownloadCfg.Lidarr.URL = fixBaseURL(cfg.DownloadCfg.Lidarr.URL) cfg.NormalizeDir() } @@ -223,6 +242,7 @@ func (cfg *Config) NormalizeDir() { cfg.ClientCfg.PlaylistDir = fixDir(cfg.ClientCfg.PlaylistDir) } cfg.DownloadCfg.Slskd.SlskdDir = fixDir(cfg.DownloadCfg.Slskd.SlskdDir) + cfg.DownloadCfg.Lidarr.LidarrDir = fixDir(cfg.DownloadCfg.Lidarr.LidarrDir) cfg.DownloadCfg.DownloadDir = fixDir(cfg.DownloadCfg.DownloadDir) } diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index a4436b91..68ac249d 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -37,7 +37,7 @@ type Metadata struct { Artist struct { ArtistCreditID int `json:"artist_credit_id"` Artists []struct { - ArtistMbid string `json:"artist_mbid"` + MusicBrainzArtistID string `json:"artist_mbid"` BeginYear int `json:"begin_year"` EndYear int `json:"end_year,omitempty"` JoinPhrase string `json:"join_phrase"` @@ -121,7 +121,7 @@ type Exploration struct { AdditionalMetadata struct { Artists []struct { ArtistCreditName string `json:"artist_credit_name"` - ArtistMbid string `json:"artist_mbid"` + MusicBrainzArtistID string `json:"artist_mbid"` JoinPhrase string `json:"join_phrase"` } `json:"artists"` CaaID int64 `json:"caa_id"` @@ -347,7 +347,7 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T MusicBrainzTrackID: mbTrackID, MusicBrainzAlbumID: rel.CaaReleaseMbid, MusicBrainzReleaseGroupID: rel.ReleaseGroupMbid, - MusicBrainzArtistID: recArtists[0].ArtistMbid, + MusicBrainzArtistID: recArtists[0].MusicBrainzArtistID, }) } @@ -396,7 +396,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( mainArtist := recording.Artist.Name mainArtistID := "" if len(recording.Artist.Artists) > 0 { - mainArtistID = recording.Artist.Artists[0].ArtistMbid + mainArtistID = recording.Artist.Artists[0].MusicBrainzArtistID } recArtists := recording.Artist.Artists @@ -550,7 +550,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( if len(recArtists) == 0 { return "" } - return recArtists[0].ArtistMbid + return recArtists[0].MusicBrainzArtistID }(), } } @@ -692,9 +692,9 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri recordingMBID = parts[len(parts)-1] } - artistMBID := "" + MusicBrainzArtistID := "" if len(trackMeta.Artists) > 0 { - artistMBID = trackMeta.Artists[0].ArtistMbid + MusicBrainzArtistID = trackMeta.Artists[0].MusicBrainzArtistID } tracks = append(tracks, &models.Track{ @@ -706,7 +706,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri Duration: track.Duration, CoverURL: coverURL, MusicBrainzTrackID: recordingMBID, - MusicBrainzArtistID: artistMBID, + MusicBrainzArtistID: MusicBrainzArtistID, MusicBrainzAlbumID: trackMeta.CaaReleaseMbid, }) } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 0235c165..4bbd70cc 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -38,6 +38,10 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL slskdClient := NewSlskd(cfg.Slskd, cfg.DownloadDir) slskdClient.AddHeader() downloader = append(downloader, slskdClient) + case "lidarr": + lidarrClient := NewLidarr(cfg.Lidarr, cfg.DownloadDir) + lidarrClient.AddHeader() + downloader = append(downloader, lidarrClient) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } @@ -101,6 +105,9 @@ func (c *DownloadClient) needsDownloadDir() bool { return true } } + if c.Cfg.Lidarr.MigrateDL { + return c.Cfg.Lidarr.MigrateDL + } return c.Cfg.Slskd.MigrateDL } diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go new file mode 100644 index 00000000..08982725 --- /dev/null +++ b/src/downloader/lidarr.go @@ -0,0 +1,333 @@ +package downloader + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + "time" + + cfg "explo/src/config" + "explo/src/models" + "explo/src/util" +) + +type Lidarr struct { + Headers map[string]string + DownloadDir string + HttpClient *util.HttpClient + Cfg cfg.Lidarr +} + +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + ArtistID int `json:"artistId"` + ForeignAlbumID string `json:"foreignAlbumId"` +} + +type LidarrTrack struct { + ArtistID int `json:"artistId"` + ForeignTrackID string `json:"foreignTrackId"` + ForeignRecordingID string `json:"foreignRecordingId"` + TrackFileID int `json:"trackFileId"` + AlbumID int `json:"albumId"` + Explicit bool `json:"explicit"` + AbsoluteTrackNumber int `json:"absoluteTrackNumber"` + TrackNumber string `json:"trackNumber"` + Title string `json:"title"` + Duration int `json:"duration"` // In milliseconds + MediumNumber int `json:"mediumNumber"` + HasFile bool `json:"hasFile"` + ID int `json:"id"` +} + +type LidarrQueue struct { + TotalRecords int `json:"totalRecords"` + Records []LidarrQueueItem `json:"records"` +} + +type LidarrQueueArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + Album LidarrQueueAlbum `json:"album"` +} + +type LidarrQueueAlbum struct { + ForeignAlbumID string `json:"foreignAlbumId"` +} + +type LidarrQueueItem struct { + ArtistID int `json:"artistId"` + AlbumID int `json:"albumId"` + Size int64 `json:"size"` + Title string `json:"title"` + SizeLeft int64 `json:"sizeleft"` + TimeLeft string `json:"timeleft"` // duration string like "00:00:00" + EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"` + Added time.Time `json:"added"` + Status string `json:"status"` + ID int64 `json:"id"` + Artist []LidarrQueueArtist `json:"artist"` +} + +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, + HttpClient: util.NewHttp(util.HttpClientConfig{Timeout: cfg.Timeout}), + DownloadDir: downloadDir, + } +} + +func (c *Lidarr) AddHeader() { + if c.Headers == nil { + c.Headers = make(map[string]string) + } + c.Headers["X-API-Key"] = c.Cfg.APIKey +} + +func (c *Lidarr) GetConf() (MonitorConfig, error) { + return MonitorConfig{ + CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute, + MonitorDuration: time.Duration(c.Cfg.MonitorConfig.Duration) * time.Minute, + MigrateDownload: c.Cfg.MigrateDL, + ToDir: c.DownloadDir, + FromDir: c.Cfg.LidarrDir, + Service: "Lidarr", + }, nil +} + +func (c *Lidarr) QueryTrack(track *models.Track) error { + trackDetails := fmt.Sprintf("%s - %s", track.Title, track.Artist) + slog.Info("initiating search", "track", trackDetails) + + if err := c.getReleaseGroupId(track); err != nil { + return fmt.Errorf("failed to get release group id for %s - %s: %w", track.Title, track.Artist, err) + } + + queryURL := fmt.Sprintf("%s/api/v1/album?foreignAlbumId=%s", c.Cfg.URL, track.MusicBrainzReleaseGroupID) + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err != nil { + return fmt.Errorf("failed to check library for album: %w", err) + } + + var libraryAlbums []Album + if err = util.ParseResp(body, &libraryAlbums); err != nil { + return fmt.Errorf("failed to unmarshal library albums: %w", err) + } + + if len(libraryAlbums) == 0 { + slog.Info("album not found in Lidarr library") + return nil + } + + libraryAlbum := libraryAlbums[0] + slog.Info("album found in Lidarr library", "album", libraryAlbum.Title, "id", libraryAlbum.ID) + track.ID = strconv.Itoa(libraryAlbum.ID) + + queryURL = fmt.Sprintf("%s/api/v1/track?artistId=%v&albumId=%v", c.Cfg.URL, libraryAlbum.ArtistID, libraryAlbum.ID) + body, err = c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err != nil { + return fmt.Errorf("failed to check existing tracks: %w", err) + } + + var lidarrTracks []LidarrTrack + if err = util.ParseResp(body, &lidarrTracks); err != nil { + return fmt.Errorf("failed to unmarshal lidarr tracks: %w", err) + } + + for _, t := range lidarrTracks { + if 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 + } + } + + return nil +} + +func (c Lidarr) GetTrack(track *models.Track) error { + + slog.Info("downloading track", + "title", track.Title, + "artist", track.Artist, + "album", track.Album, + ) + if track.Present { + return nil + } + + if track.ID != "" { + albumID, err := strconv.Atoi(track.ID) + if err != nil { + return fmt.Errorf("invalid lidarr album ID: %w", err) + } + slog.Info("album in library but track missing, triggering search", "album", track.Album, "id", albumID) + payload := map[string]any{ + "name": "AlbumSearch", + "albumIds": []int{albumID}, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal error: %w", err) + } + _, err = c.HttpClient.MakeRequest("POST", fmt.Sprintf("%s/api/v1/command", c.Cfg.URL), bytes.NewReader(body), c.Headers) + if err != nil { + return fmt.Errorf("failed to trigger album search: %w", err) + } + track.File = track.Title + return nil + } + + rootFolder, err := c.getRootDirectory() + if err != nil { + return fmt.Errorf("could not look up root directory: %w", err) + } + + payload := map[string]any{ + "foreignAlbumId": track.MusicBrainzReleaseGroupID, + "monitored": true, + "anyReleaseOk": true, + "artist": map[string]any{ + "qualityProfileId": rootFolder.DefaultQualityProfileId, + "metadataProfileId": rootFolder.DefaultMetadataProfileId, + "foreignArtistId": track.MusicBrainzArtistID, + "rootFolderPath": rootFolder.Path, + }, + "addOptions": AddOptions{ + SearchForNewAlbum: true, + }, + } + + body, 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) + 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 +} + +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) + + for _, record := range queue.Records { + // 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), + } + } + + 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) + } + + 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 { + if total == 0 { + return 0 + } + return float64(total-remaining) / float64(total) * 100 +} + +func (c Lidarr) deleteDownload(ID string) error { + reqParams := fmt.Sprintf("/api/v1/queue/%s", ID) + + 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) + } + return nil +} + +func (c *Lidarr) Cleanup(track models.Track, fileID string) error { + if err := c.deleteDownload(fileID); err != nil { + slog.Info(fmt.Sprintf("[lidarr] failed to delete download: %v", err)) + } + return nil +} diff --git a/src/web/backend/defs.go b/src/web/backend/defs.go index b5b7b97e..65e8d659 100644 --- a/src/web/backend/defs.go +++ b/src/web/backend/defs.go @@ -111,6 +111,19 @@ package backend VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, }, + { + Key: "LIDARR_URL", Label: "Lidarr URL", + Type: "url", Section: "downloader", + Placeholder: "e.g. http://192.168.1.100:8686", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "lidarr"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "lidarr"}, + }, + { + Key: "LIDARR_API_KEY", Label: "Lidarr API Key", + Type: "text", Section: "downloader", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "lidarr"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "lidarr"}, + }, } */ // FieldDef describes a single configurable env var. @@ -159,5 +172,6 @@ var allConfigKeys = []string{ "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "SLSKD_URL", "SLSKD_API_KEY", + "LIDARR_URL", "LIDARR_API_KEY", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", } diff --git a/src/web/backend/server.go b/src/web/backend/server.go index dcdabff6..174bc175 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -69,11 +69,11 @@ func newManualRunState() manualRunState { } type Server struct { - cfg config.ServerConfig + cfg config.ServerConfig mux *http.ServeMux server *http.Server authStore *AuthStore - cronJobs *Jobs + cronJobs *Jobs sessionManager *SessionManager manualRun manualRunState } @@ -87,25 +87,25 @@ func NewServer(cfg config.ServerConfig) *Server { ) authStore := NewAuthStore( - cfg.Username, - cfg.Password, - sessionManager, -) + cfg.Username, + cfg.Password, + sessionManager, + ) cronJobs := NewJobs() mux := http.NewServeMux() s := &Server{ cfg: cfg, - mux: mux, + mux: mux, server: &http.Server{ Addr: cfg.Port, Handler: sessionManager.Handle(mux), }, - authStore: authStore, - cronJobs: cronJobs, + authStore: authStore, + cronJobs: cronJobs, sessionManager: sessionManager, - manualRun: newManualRunState(), + manualRun: newManualRunState(), } s.registerRoutes() @@ -144,8 +144,13 @@ func checkForUpdate() { c := parseVer(config.Version) newer := false for i := range 3 { - if l[i] > c[i] { newer = true; break } - if l[i] < c[i] { break } + if l[i] > c[i] { + newer = true + break + } + if l[i] < c[i] { + break + } } if newer { slog.Info("new version available!", "latest", release.TagName, "current", config.Version) @@ -181,7 +186,7 @@ func (s *Server) startJobs() { s.cronJobs.Start() } -func(s *Server) PrefetchCovers() { +func (s *Server) PrefetchCovers() { coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") @@ -385,7 +390,7 @@ func parseEnvText(text string) map[string]string { if len(v) >= 2 { if (v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"') { - v = v[1 : len(v)-1] + v = v[1 : len(v)-1] } } out[k] = v @@ -559,7 +564,7 @@ func updateEnvKeys(path string, updates map[string]string, fallback []byte) erro // Append any keys that weren't already in the file for k, v := range updates { if !touched[k] && v != "" { - lines = append(lines, k + "=" + formatEnvValue(v)) + lines = append(lines, k+"="+formatEnvValue(v)) } } @@ -695,7 +700,9 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { FilterList string `json:"filter_list"` SlskdURL string `json:"slskd_url"` SlskdAPIKey string `json:"slskd_api_key"` - Extensions string `json:"extensions"` // slskd + LidarrURL string `json:"lidarr_url"` + LidarrAPIKey string `json:"lidarr_api_key"` + Extensions string `json:"extensions"` // slskd } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -725,8 +732,10 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { "FILTER_LIST": body.FilterList, "SLSKD_URL": body.SlskdURL, "SLSKD_API_KEY": body.SlskdAPIKey, - "EXTENSIONS": body.Extensions, // slskd - "WIZARD_COMPLETE": "true", + "LIDARR_URL": body.LidarrURL, + "LIDARR_API_KEY": body.LidarrAPIKey, + "EXTENSIONS": body.Extensions, // slskd + "WIZARD_COMPLETE": "true", } if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index c5b4a7d8..fea69c5e 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -251,17 +251,18 @@ function Collapse({ open, children }) { } // ── Step 3: Downloader ──────────────────────────────────────────────────────── -// Collects download service selection (YouTube, Slskd) and their respective +// Collects download service selection (YouTube, Slskd, Lidarr) and their respective // credentials, download directory, and file format preferences. function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { const { downloadDir, useSubdirectory, migrateDownloads, dlServices, - youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey, extensions } = fields + youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey, lidarrUrl, lidarrApiKey, extensions } = fields const isLocked = key => envSources[key] === 'env' const valid = () => { if (!Object.values(dlServices).some(Boolean)) return false if (dlServices.slskd && (!slskdUrl.trim() || !slskdApiKey.trim())) return false + if (dlServices.lidarr && (!lidarrUrl.trim() || !lidarrApiKey.trim())) return false return true } @@ -378,6 +379,69 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { + + {/* Lidarr section */} +
+ By default, lidarr saves tracks to whichever download path is configured in your lidarr instance. +
+