diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f658412b..cb67a307 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 branches: - dev + - feat/add-lidarr-download-config pull_request: branches: ['*'] workflow_dispatch: @@ -133,7 +134,7 @@ jobs: build-dev: name: Build/publish dev image runs-on: ubuntu-latest - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/feat/add-lidarr-download-config' permissions: contents: read packages: write diff --git a/sample.env b/sample.env index 68511ba1..a35c6972 100644 --- a/sample.env +++ b/sample.env @@ -103,6 +103,17 @@ LIBRARY_NAME= # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella +# === Lidarr Configuration === + +# LIDARR_API_KEY= +# LIDARR_RETRY= +# LIDARR_DL_ATTEMPTS= +# LIDARR_DIR= +# MIGRATE_DOWNLOADS= +# LIDARR_TIMEOUT= +# LIDARR_SCHEME= +# LIDARR_URL= + # === Metadata / Formatting === # Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true) diff --git a/src/config/config.go b/src/config/config.go index 512c6a93..32424108 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -94,14 +94,15 @@ type SubsonicConfig struct { } type DownloadConfig struct { - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - PathTemplate string `env:"PATH_TEMPLATE"` + DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` + PathTemplate string `env:"PATH_TEMPLATE"` Youtube Youtube YoutubeMusic YoutubeMusic Slskd Slskd + Lidarr Lidarr ExcludeLocal bool - DownloadLimiter int `env:"DOWNLOAD_LIMITER" env-default:"1"` // rate limit download operations - OverwriteMetadata bool `env:"OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads + DownloadLimiter int `env:"DOWNLOAD_LIMITER" env-default:"1"` // rate limit download operations + OverwriteMetadata bool `env:"OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"` @@ -135,26 +136,48 @@ type YoutubeMusic struct { } type Slskd struct { - APIKey string `env:"SLSKD_API_KEY"` - URL string `env:"SLSKD_URL"` - Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track - DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track - SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` - MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir - Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` - Filters Filters - MonitorConfig SlskdMon + APIKey string `env:"SLSKD_API_KEY"` + URL string `env:"SLSKD_URL"` + Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` + MigrateDL bool `env:"SLSKD_MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir + MigrateDLOld bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir (Old) + RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format (will be deprecated) + PathTemplate string `env:"PATH_TEMPLATE"` + OverwriteMetadata bool `env:"SLSKD_OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads + KeepPermissions bool `env:"SLSKD_KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download + Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` + Filters Filters + MonitorConfig SlskdMon } type SlskdMon struct { - Interval int `env:"SLSKD_MONITOR_INTERVAL" env-default:"1"` - Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` + Interval int `env:"SLSKD_MONITOR_INTERVAL" env-default:"1"` // in minutes + Duration int `env:"SLSKD_MONITOR_DURATION" env-default:"15"` // in minutes +} + +type Lidarr struct { + APIKey string `env:"LIDARR_API_KEY"` + URL string `env:"LIDARR_URL"` + Retry int `env:"LIDARR_RETRY" env-default:"8"` // Number of times to check search status before skipping the track + LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"` + MigrateDL bool `env:"LIDARR_MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir + Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"` + RootFolder string `env:"LIDARR_ROOT_FOLDER" env-default:"Music"` // Root folder name to use in Lidarr + Filters Filters + MonitorConfig LidarrMon +} + +type LidarrMon struct { + Interval int `env:"LIDARR_MONITOR_INTERVAL" env-default:"1"` // in minutes + Duration int `env:"LIDARR_MONITOR_DURATION" env-default:"20"` // in minutes } type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` ArtistBlacklist []string `env:"ARTIST_BLACKLIST"` - Listenbrainz Listenbrainz + Listenbrainz Listenbrainz } type Listenbrainz struct { Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` @@ -224,6 +247,7 @@ func (cfg *Config) CommonFixes() { cfg.DownloadCfg.Youtube.CoversDir = filepath.Join(filepath.Dir(cfg.ServerCfg.WebDataDir), "cache", "covers") cfg.ClientCfg.URL = fixBaseURL(cfg.ClientCfg.URL) cfg.DownloadCfg.Slskd.URL = fixBaseURL(cfg.DownloadCfg.Slskd.URL) + cfg.DownloadCfg.Lidarr.URL = fixBaseURL(cfg.DownloadCfg.Lidarr.URL) cfg.NormalizeDir() } @@ -232,6 +256,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) } @@ -253,11 +278,9 @@ func fixBaseURL(rawURL string) string { return strings.TrimRight(u, "/") } -func (cfg *Config) HandleDeprecation() { // - if cfg.Debug { - slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") - cfg.LogLevel = "DEBUG" - } + +// deprecation logs and remappings +func (cfg *Config) HandleDeprecation() { if cfg.Flags.PersistSet { slog.Warn("--persist flag now only handles playlist deletion, use toggle in UI or --clean-downloads to delete tracks") } @@ -269,6 +292,18 @@ func (cfg *Config) HandleDeprecation() { // if cfg.Flags.CleanDownloads && !cfg.DownloadCfg.UseSubDir { slog.Warn("Deleting tracks requires 'USE_SUBDIRECTORY' to be true") } + + if cfg.DownloadCfg.OverwriteMetadata { + cfg.DownloadCfg.Slskd.OverwriteMetadata = cfg.DownloadCfg.OverwriteMetadata + } + + if !cfg.DownloadCfg.KeepPermissions { + cfg.DownloadCfg.Slskd.KeepPermissions = cfg.DownloadCfg.KeepPermissions + } + + if cfg.DownloadCfg.Slskd.MigrateDLOld { + cfg.DownloadCfg.Slskd.MigrateDL = cfg.DownloadCfg.Slskd.MigrateDLOld + } } // Generate playlist name and description diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index c7f3509d..01cd10ae 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 }(), } } @@ -641,6 +641,13 @@ func FetchTopRecordings(httpClient *util.HttpClient, user string) ([]*models.Tra return lb.getTopRecordings(user) } +// EnrichTracks fetches MusicBrainz metadata for tracks that have recording MBIDs, +// populating fields like MusicBrainzReleaseGroupID and MusicBrainzArtistID. +func EnrichTracks(httpClient *util.HttpClient, lbCfg cfg.Listenbrainz, tracks []*models.Track) ([]*models.Track, error) { + lb := &ListenBrainz{HttpClient: httpClient, cfg: lbCfg} + return lb.enrichTracks(tracks, lbCfg.SingleArtist) +} + // FetchMostRecentPlaylistByType finds and fetches the most recent LB-generated playlist of the given type for the user. func FetchMostRecentPlaylistByType(httpClient *util.HttpClient, user, playlistType string) ([]*models.Track, error) { lb := &ListenBrainz{HttpClient: httpClient, cfg: cfg.Listenbrainz{ImportPlaylist: playlistType}} @@ -705,9 +712,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 +726,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri Duration: track.Duration, CoverURL: coverURL, MusicBrainzTrackID: recordingMBID, - MusicBrainzArtistID: artistMBID, + MusicBrainzArtistID: MusicBrainzArtistID, MusicBrainzAlbumID: trackMeta.CaaReleaseMbid, }) } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 9451f66f..3d9c0000 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -44,6 +44,13 @@ 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() + if err := lidarrClient.getRootDirectory(); err != nil { + return nil, err + } + downloader = append(downloader, lidarrClient) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } @@ -124,6 +131,9 @@ func (c *DownloadClient) needsDownloadDir() bool { return true } } + if c.Cfg.Lidarr.MigrateDL { + return c.Cfg.Lidarr.MigrateDL + } return c.Cfg.Slskd.MigrateDL } @@ -246,98 +256,6 @@ func buildTrackPath(template string, track *models.Track) string { return filepath.Clean(result) } -func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { - trackDir := filepath.Join(srcDir, trackPath) - srcFile := filepath.Join(trackDir, track.File) - - if c.Cfg.RenameTrack { // Rename file to {title}-{artist} format - track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) - } - if c.Cfg.OverwriteMetadata { - metadata := util.BuildffmpegMetadata(*track) - if err := overwriteMetadata(metadata, srcFile); err != nil { - slog.Warn("problem overwriting metadata", "msg", err.Error()) - } - } - - in, err := os.Open(srcFile) - if err != nil { - return fmt.Errorf("couldn't open source file: %s", err.Error()) - } - - defer func() { - if cerr := in.Close(); cerr != nil { - slog.Error(fmt.Sprintf("failed to close source file: %s", cerr.Error())) - } - }() - - var dstFile string - - if c.Cfg.PathTemplate != "" { - relativePath := buildTrackPath(c.Cfg.PathTemplate, track) - track.File = filepath.Base(relativePath) - if track.File == "." || track.File == string(filepath.Separator) { - track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) - relativePath = filepath.Dir(relativePath) + string(filepath.Separator) + track.File - slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) - } - dstFile = filepath.Join(destDir, relativePath) - } else { - if err = os.MkdirAll(destDir, os.ModePerm); err != nil { - return fmt.Errorf("couldn't make download directory: %s", err.Error()) - } - - dstFile = filepath.Join(destDir, track.File) - } - if err = os.MkdirAll(filepath.Dir(dstFile), os.ModePerm); err != nil { - return fmt.Errorf("couldn't make destination directory: %s", err.Error()) - } - - out, err := os.Create(dstFile) - if err != nil { - return fmt.Errorf("couldn't create destination file: %s", err.Error()) - } - - defer func() { - if cerr := out.Close(); cerr != nil { - slog.Error(fmt.Sprintf("failed to close destination file: %s", cerr.Error())) - } - }() - - if _, err = io.Copy(out, in); err != nil { - return fmt.Errorf("copy failed: %s", err.Error()) - } - - if err = out.Sync(); err != nil { - return fmt.Errorf("sync failed: %s", err.Error()) - } - - if c.Cfg.KeepPermissions { - info, err := os.Stat(srcFile) - if err != nil { - return fmt.Errorf("stat error: %s", err.Error()) - } - if err = os.Chmod(dstFile, info.Mode()); err != nil { - return fmt.Errorf("chmod failed: %s", err.Error()) - } - } - - if err = os.Remove(srcFile); err != nil { - return fmt.Errorf("failed to delete original file: %s", err.Error()) - } - - isEmpty, err := isDirEmpty(trackDir) - if err != nil { - return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) - } else if isEmpty { - if err = os.Remove(trackDir); err != nil { - return fmt.Errorf("failed to remove empty directory: %s", err.Error()) - } - } - - return nil -} - func overwriteMetadata(metadata []string, srcFile string) error { opts := ffmpeg.KwArgs{ "c": "copy", diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go new file mode 100644 index 00000000..da8686e9 --- /dev/null +++ b/src/downloader/lidarr.go @@ -0,0 +1,665 @@ +package downloader + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "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 + AlbumCache map[string]*AlbumMetadata + CacheMu sync.RWMutex + RootFolder RootFolder +} + +type AlbumMetadata struct { + AlbumID string + ArtistID string + ReleaseGroupMBID string + ArtistMBID string + + Tracks func() []LidarrTrack +} + +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + ArtistID int `json:"artistId"` + ForeignAlbumID string `json:"foreignAlbumId"` + Artist struct { + ForeignArtistID string `json:"foreignArtistId"` + } `json:"artist"` +} + +type LidarrTrack struct { + ArtistID int `json:"artistId"` + ForeignTrackID string `json:"foreignTrackId"` + ForeignRecordingID string `json:"foreignRecordingId"` + TrackFileID int `json:"trackFileId"` + AlbumID int `json:"albumId"` + Explicit bool `json:"explicit"` + AbsoluteTrackNumber int `json:"absoluteTrackNumber"` + TrackNumber string `json:"trackNumber"` + Title string `json:"title"` + Duration int `json:"duration"` // In milliseconds + MediumNumber int `json:"mediumNumber"` + HasFile bool `json:"hasFile"` + ID int `json:"id"` +} + +type LidarrQueue struct { + TotalRecords int `json:"totalRecords"` + Records []LidarrQueueItem `json:"records"` +} + +type LidarrQueueArtist struct { + ForeignArtistID string `json:"foreignArtistId"` + Album LidarrQueueAlbum `json:"album"` +} + +type LidarrQueueAlbum struct { + ForeignAlbumID string `json:"foreignAlbumId"` +} + +type LidarrQueueItem struct { + ArtistID int `json:"artistId"` + AlbumID int `json:"albumId"` + Size int64 `json:"size"` + Title string `json:"title"` + SizeLeft int64 `json:"sizeleft"` + TimeLeft string `json:"timeleft"` // duration string like "00:00:00" + EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"` + Added time.Time `json:"added"` + Status string `json:"status"` + ID int `json:"id"` + Artist []LidarrQueueArtist `json:"artist"` +} + +type LidarrHistory struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + SortKey string `json:"sortKey"` + SortDirection string `json:"sortDirection"` + TotalRecords int `json:"totalRecords"` + Records []LidarrRecord `json:"records"` +} + +type LidarrRecord struct { + AlbumID int `json:"albumId"` + ArtistID int `json:"artistId"` + TrackID int `json:"trackId"` + SourceTitle string `json:"sourceTitle"` + QualityCutoffNotMet bool `json:"qualityCutoffNotMet"` + Data Data `json:"data"` + Track LidarrTrack `json:"track"` + ID int `json:"id"` +} + +type Data struct { + FileID string `json:"fileId"` + DroppedPath string `json:"droppedPath"` + ImportedPath string `json:"importedPath"` + DownloadClient string `json:"downloadClient"` + ReleaseGroup any `json:"releaseGroup"` + Size string `json:"size"` + IndexerFlags string `json:"indexerFlags"` +} + +type RootFolder struct { + Name string `json:"name"` + Path string `json:"path"` + DefaultMetadataProfileId int `json:"defaultMetadataProfileId"` + DefaultQualityProfileId int `json:"defaultQualityProfileId"` +} + +type AddOptions struct { + SearchForNewAlbum bool `json:"searchForNewAlbum"` +} + +type LidarrCommands []struct { + Name string `json:"name"` + CommandName string `json:"commandName"` + Message string `json:"message,omitempty"` + Body CommandsBody `json:"body,omitempty"` + Priority string `json:"priority"` + Status string `json:"status"` + Result string `json:"result"` + Queued time.Time `json:"queued"` + Started time.Time `json:"started,omitempty"` + Trigger string `json:"trigger"` + StateChangeTime time.Time `json:"stateChangeTime,omitempty"` + SendUpdatesToClient bool `json:"sendUpdatesToClient"` + UpdateScheduledTask bool `json:"updateScheduledTask"` + ID int `json:"id"` + Ended time.Time `json:"ended,omitempty"` + Duration string `json:"duration,omitempty"` + LastExecutionTime time.Time `json:"lastExecutionTime,omitempty"` +} + +type CommandsBody struct { + AlbumIds []int `json:"albumIds"` + SendUpdatesToClient bool `json:"sendUpdatesToClient"` + UpdateScheduledTask bool `json:"updateScheduledTask"` + RequiresDiskAccess bool `json:"requiresDiskAccess"` + IsExclusive bool `json:"isExclusive"` + IsTypeExclusive bool `json:"isTypeExclusive"` + IsLongRunning bool `json:"isLongRunning"` + Name string `json:"name"` + Trigger string `json:"trigger"` + SuppressMessages bool `json:"suppressMessages"` +} + +func NewLidarr(cfg cfg.Lidarr, downloadDir string) *Lidarr { // init downloader cfg for lidarr + return &Lidarr{ + Cfg: cfg, + HttpClient: util.NewHttp(util.HttpClientConfig{Timeout: cfg.Timeout}), + DownloadDir: downloadDir, + } +} + +func (c *Lidarr) AddHeader() { + if c.Headers == nil { + c.Headers = make(map[string]string) + } + c.Headers["X-API-Key"] = c.Cfg.APIKey +} + +func (c *Lidarr) GetConf() (MonitorConfig, error) { + return MonitorConfig{ + CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute, + MonitorDuration: time.Duration(c.Cfg.MonitorConfig.Duration) * time.Minute, + MigrateDownload: c.Cfg.MigrateDL, + ToDir: c.DownloadDir, + FromDir: c.Cfg.LidarrDir, + Service: "Lidarr", + }, nil +} + +func (c *Lidarr) QueryTrack(track *models.Track) error { + trackDetails := fmt.Sprintf("%s - %s", track.Title, track.Artist) + slog.Info("initiating search", "track", trackDetails) + + if c.populateFromCache(track) { + return c.findTrackID(track) +} + + if err := c.getReleaseGroupId(track); err != nil { + return fmt.Errorf("failed to get release group id for %s - %s: %w", track.Title, track.Artist, err) + } + + 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] + + track.AlbumID = strconv.Itoa(libraryAlbum.ID) + track.MainArtistID = strconv.Itoa(libraryAlbum.ArtistID) + + c.cacheAlbum(track) + + slog.Info("album found in Lidarr library", "album", libraryAlbum.Title, "id", libraryAlbum.ID) + if err := c.findTrackID(track); err != nil { + return fmt.Errorf("failed to get track ID: %w", err) + } + return nil +} + +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 c.populateFromCache(track) { + return c.findTrackID(track) + } + + if track.AlbumID != "" { + albumID, err := strconv.Atoi(track.AlbumID) + if err != nil { + return fmt.Errorf("invalid lidarr album ID: %w", err) + } + slog.Info("album in library but track missing, triggering search", "album", track.Album, "id", albumID) + + if err = c.triggerAlbumSearch(albumID); err != nil { + return fmt.Errorf("failed to trigger album search: %w", err) + } + + c.cacheAlbum(track) + + if err := c.searchStatus(albumID, 1); err != nil { + return fmt.Errorf("album search failed: %w", err) + } + + track.File = track.CleanTitle + return nil + } + + if err := c.addNewAlbum(track); err != nil { + return fmt.Errorf("failed to add album: %w", err) + } + + if c.populateFromCache(track) { + return c.findTrackID(track) + } + track.File = track.CleanTitle + + slog.Info("download started", "AlbumID", track.AlbumID) + return nil +} + +func (c *Lidarr) addNewAlbum(track *models.Track) error { + + payload := map[string]any{ + "foreignAlbumId": track.MusicBrainzReleaseGroupID, + "monitored": true, + "anyReleaseOk": true, + "artist": map[string]any{ + "qualityProfileId": c.RootFolder.DefaultQualityProfileId, + "metadataProfileId": c.RootFolder.DefaultMetadataProfileId, + "foreignArtistId": track.MusicBrainzArtistID, + "rootFolderPath": c.RootFolder.Path, + }, + "addOptions": AddOptions{ + SearchForNewAlbum: true, + }, + } + + payloadBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal error: %w", err) + } + queryURL := fmt.Sprintf("%s/api/v1/album", c.Cfg.URL) + + body, err := c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(payloadBody), c.Headers) + if err != nil { + if strings.Contains(err.Error(), "got 409") || strings.Contains(string(body), "has already been added") { + if _, ok := c.AlbumCache[c.cacheKey(track)]; ok { + slog.Debug("album already in Lidarr", "album", track.MusicBrainzReleaseGroupID) + return nil + } + return fmt.Errorf("album present in lidarr but explo couldn't find it") + } + return err + } + var album Album + if err = util.ParseResp(body, &album); err != nil { + return fmt.Errorf("failed to unmarshal lidarr album: %w", err) + } + track.AlbumID = strconv.Itoa(album.ID) + track.MainArtistID = strconv.Itoa(album.ArtistID) + c.cacheAlbum(track) + if err := c.searchStatus(album.ID, 1); err != nil { + return fmt.Errorf("album search failed: %w", err) + } + return nil +} + +func (c *Lidarr) triggerAlbumSearch(albumID int) error { + payload := map[string]any{ + "name": "AlbumSearch", + "albumIds": []int{albumID}, + } + payloadBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal error: %w", err) + } + _, err = c.HttpClient.MakeRequest("POST", fmt.Sprintf("%s/api/v1/command", c.Cfg.URL), bytes.NewReader(payloadBody), c.Headers) + if err != nil { + return err + } + return nil +} + +func (c *Lidarr) getAlbumTracks(albumID string, artistID string) []LidarrTrack { + queryURL := fmt.Sprintf("%s/api/v1/track?albumId=%s&artistId=%s", c.Cfg.URL, albumID, artistID) + for attempt := 1; attempt <= 3; attempt++ { + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err == nil { + var tracks []LidarrTrack + if err := util.ParseResp(body, &tracks); err == nil { + return tracks + } + } + slog.Warn("failed loading album tracks", "attempt", attempt, "err", err) + + if attempt < 3 { + time.Sleep(time.Second) + } + } + return nil +} + +func (c *Lidarr) findTrackID(track *models.Track) error { + key := c.cacheKey(track) + + c.CacheMu.RLock() + a, ok := c.AlbumCache[key] + c.CacheMu.RUnlock() + + if !ok { + return fmt.Errorf("album not cached") + } + + alnumTrackTitle := util.AlnumOnly(track.CleanTitle) + for _, t := range a.Tracks() { + mbIDMatch := (track.MusicBrainzReleaseTrackID != "" && track.MusicBrainzReleaseTrackID == t.ForeignTrackID) || + (track.MusicBrainzTrackID != "" && track.MusicBrainzTrackID == t.ForeignRecordingID) + alnumLidarrTitle := util.AlnumOnly(t.Title) + titleMatch := util.ContainsFold(alnumLidarrTitle, alnumTrackTitle) + + if mbIDMatch || titleMatch { + if t.HasFile { + track.Present = true + slog.Info("track already present in Lidarr", "track", track.CleanTitle, "album", track.Album, "artist", track.MainArtist) + } + track.ID = strconv.Itoa(t.ID) + return nil + } + } + return fmt.Errorf("could not find track '%s' from Album: %s", track.CleanTitle, track.Album) +} + +// Check whether album search is completed +func (c *Lidarr) searchStatus(AlbumID, count int) error { + reqParams := "/api/v1/command" + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParams, nil, c.Headers) + if err != nil { + return err + } + + var commands LidarrCommands + if err := util.ParseResp(body, &commands); err != nil { + return err + } + for _, command := range commands { + if command.Name != "AlbumSearch" || + !slices.Contains(command.Body.AlbumIds, AlbumID) { + continue + } + switch command.Status { + case "completed": + return nil + case "failed", "aborted", "cancelled", "orphaned": + return fmt.Errorf("index search %s, skipping album", command.Status) + } + } + if count >= c.Cfg.Retry { + return fmt.Errorf("search wasn't completed after %d retries, skipping album %d", count, AlbumID) + } + slog.Debug("waiting for Lidarr album search", "albumID", AlbumID, "attempt", count, "maxAttempts", c.Cfg.Retry) + time.Sleep(15 * time.Second) + return c.searchStatus(AlbumID, count+1) +} + +func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + + albums := make(map[string][]*models.Track) + + for _, track := range tracks { + if track.ID == "" || track.AlbumID == "" || track.Present { + continue + } + albums[track.AlbumID] = append(albums[track.AlbumID], track) + } + statuses := make(map[string]FileStatus) + queues, err := c.getQueue() + if err != nil { + slog.Warn("[lidarr] failed to check queue", "err", err) + } + + queueMap := make(map[string]LidarrQueueItem) + + for _, queue := range queues { + for _, record := range queue.Records { + queueMap[strconv.Itoa(record.AlbumID)] = record + } + } + + // check for completed downloads and build map of tracks that might be in queue + for albumID, albumTracks := range albums { + + history ,err := c.getHistory(albumID) + if err != nil { + slog.Warn("[lidarr] failed to check download history", "err", err) + } + + for _, r := range history.Records { + for _, track := range albumTracks { + if _, exists := statuses[track.ID]; exists { + continue + } + trackIDMatch := track.ID == strconv.Itoa(r.TrackID) + mbTrack := track.MusicBrainzTrackID + mbReleaseTrack := track.MusicBrainzReleaseTrackID + mbIDMatch := (mbTrack != "" && mbTrack == r.Track.ForeignTrackID) || (mbReleaseTrack != "" && mbReleaseTrack == r.Track.ForeignTrackID) + if (mbIDMatch || trackIDMatch) && r.Data.ImportedPath != "" { + statuses[track.ID] = FileStatus{ + ID: track.ID, + Filename: r.Data.ImportedPath, + State: "Succeeded", + BytesTransferred: 1, + BytesRemaining: 0, + PercentComplete: 100, + QueueID: "", + } + } + } + } + + record, ok := queueMap[albumID] + if !ok { + continue + } + + for _, track := range albumTracks { + if _, e := statuses[track.ID]; e { + continue + } + // assign all tracks in an album the same state + statuses[track.ID] = FileStatus{ + ID: track.ID, + State: record.Status, + BytesRemaining: int(record.SizeLeft), + BytesTransferred: int(record.Size - record.SizeLeft), + PercentComplete: percent(record.Size, record.SizeLeft), + QueueID: strconv.Itoa(record.ID), + } + } + + } + return statuses, nil +} + +func (c *Lidarr) getQueue() ([]LidarrQueue, error) { + + var totalPages = 1 + pageSize := 50 + var queues []LidarrQueue + for page := 1; page <= totalPages; page++ { + req := fmt.Sprintf("/api/v1/queue?pageSize=%d&page=%d", pageSize, page) + + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, c.Headers) + if err != nil { + return nil, err + } + + var queue LidarrQueue + if err := util.ParseResp(body, &queue); err != nil { + return nil, err + } + queues = append(queues, queue) + if page == 1 { + totalPages = (queue.TotalRecords + pageSize - 1) / pageSize + } + } + return queues, nil +} + +// Queries Lidarr history for specified album +func (c *Lidarr) getHistory(albumID string) (LidarrHistory, error) { + + req := fmt.Sprintf("/api/v1/history?albumId=%s&pageSize=1111", albumID) + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+req, nil, c.Headers) + if err != nil { + return LidarrHistory{}, err + } + var history LidarrHistory + if err := util.ParseResp(body, &history); err != nil { + return LidarrHistory{}, err + } + + return history, nil +} + +func (c *Lidarr) getRootDirectory() error { + // Get the defaults from the root dir + queryURL := fmt.Sprintf("%s/api/v1/rootfolder", c.Cfg.URL) + body, err := c.HttpClient.MakeRequest("GET", queryURL, nil, c.Headers) + if err != nil { + return 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 root folder: %w", err) + } + + if len(rootFolders) == 0 { + return fmt.Errorf("no root folders found in Lidarr") + } + for _, folder := range rootFolders { + if folder.Name == c.Cfg.RootFolder { + c.RootFolder = folder + return nil + } + } + return fmt.Errorf("no root folder named '%s' found, please create one in Lidarr or point explo to a correct folder", c.Cfg.RootFolder) +} + +func (c *Lidarr) getReleaseGroupId(track *models.Track) error { + if track.MusicBrainzReleaseGroupID != "" && track.MusicBrainzArtistID != "" { + 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 + track.MusicBrainzArtistID = albums[0].Artist.ForeignArtistID + return nil +} + +func percent(total, remaining int64) float64 { + if total == 0 { + return 0 + } + return float64(total-remaining) / float64(total) * 100 +} + +func (c *Lidarr) deleteDownload(ID string) error { + reqParams := fmt.Sprintf("/api/v1/queue/%s", ID) + + time.Sleep(1 * time.Second) + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParams+"?removeFromClient=true", nil, c.Headers); err != nil { + return fmt.Errorf("hard delete failed: %w", err) + } + return nil +} + +func (c *Lidarr) Cleanup(track models.Track, queueID string) error { + if track.Present { // dont clear downloaded files from download client + return nil + } + if err := c.deleteDownload(queueID); err != nil { + slog.Info(fmt.Sprintf("[lidarr] failed to delete download: %v", err)) + } + return nil +} + +func (c *Lidarr) cacheKey(track *models.Track) string { + return track.MainArtist + "|" + track.Album +} + +func (c *Lidarr) cacheAlbum(track *models.Track) { + key := c.cacheKey(track) + c.CacheMu.Lock() + defer c.CacheMu.Unlock() + c.AlbumCache[key] = &AlbumMetadata{ + ReleaseGroupMBID: track.MusicBrainzReleaseGroupID, + ArtistMBID: track.MusicBrainzArtistID, + AlbumID: track.AlbumID, + ArtistID: track.MainArtistID, + + Tracks: sync.OnceValue(func() []LidarrTrack { + return c.getAlbumTracks(track.AlbumID, track.MainArtistID) + }), + } +} +// Checks if album is cached. Populates track fields if it is +func (c *Lidarr) populateFromCache(track *models.Track) bool { + c.CacheMu.RLock() + a, ok := c.AlbumCache[c.cacheKey(track)] + c.CacheMu.RUnlock() + + if !ok { + return false + } + + track.MusicBrainzReleaseGroupID = a.ReleaseGroupMBID + track.MusicBrainzArtistID = a.ArtistMBID + track.AlbumID = a.AlbumID + track.MainArtistID = a.ArtistID + return true +} + + +func (c *Lidarr) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { + return nil +} \ No newline at end of file diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 79d10dee..75e28ac7 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -12,6 +12,7 @@ import ( type Monitor interface { GetDownloadStatus([]*models.Track) (map[string]FileStatus, error) GetConf() (MonitorConfig, error) + MoveDownload(string, string, string, *models.Track) error Cleanup(models.Track, string) error } @@ -32,6 +33,7 @@ type FileStatus struct { BytesTransferred int `json:"bytesTransferred"` BytesRemaining int `json:"bytesRemaining"` PercentComplete float64 `json:"percentComplete"` + QueueID string `json:"queueID"` } func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) error { @@ -51,6 +53,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err if err != nil { return fmt.Errorf("[%s/monitor] error fetching download status: %s", monCfg.Service, err.Error()) } + slog.Debug("fetched download queue", "size", len(statuses)) currentTime := time.Now().Local() @@ -70,7 +73,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err LastUpdated: currentTime, } } - fileStatus, exists := statuses[track.File] + fileStatus, exists := statuses[track.ID] tracker := progressMap[key] if !exists { tracker.Counter++ @@ -82,13 +85,14 @@ 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 - track.File, path = parsePath(track.File) + var filePath string + track.File, filePath = parsePath(track.File) if monCfg.MigrateDownload { - if err = c.MoveDownload(monCfg.FromDir, monCfg.ToDir, path, track); err != nil { + if err = m.MoveDownload(monCfg.FromDir, monCfg.ToDir, filePath, track); err != nil { slog.Error("error while moving file", "err", err.Error()) } else { slog.Info("track moved successfully", "service", monCfg.Service) @@ -96,7 +100,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 +114,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } else if currentTime.Sub(tracker.LastUpdated) > monCfg.MonitorDuration || fileStatus.State == "Errored" { slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "file", track.File, "duration", monCfg.MonitorDuration) tracker.Skipped = true - if err = m.Cleanup(*track, fileStatus.ID); err != nil { + if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) } continue diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index c6ff7418..3dddda84 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -14,6 +14,8 @@ import ( "slices" "strings" "time" + "os" + "io" ) type Search struct { @@ -482,3 +484,96 @@ func normalize(state string) string{ } return state } + + +func (c *Slskd) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { + trackDir := filepath.Join(srcDir, trackPath) + srcFile := filepath.Join(trackDir, track.File) + + if c.Cfg.RenameTrack { // Rename file to {title}-{artist} format + track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) + } + if c.Cfg.OverwriteMetadata { + metadata := util.BuildffmpegMetadata(*track) + if err := overwriteMetadata(metadata, srcFile); err != nil { + slog.Warn("problem overwriting metadata", "msg", err.Error()) + } + } + + in, err := os.Open(srcFile) + if err != nil { + return fmt.Errorf("couldn't open source file: %s", err.Error()) + } + + defer func() { + if cerr := in.Close(); cerr != nil { + slog.Error(fmt.Sprintf("failed to close source file: %s", cerr.Error())) + } + }() + + var dstFile string + + if c.Cfg.PathTemplate != "" { + relativePath := buildTrackPath(c.Cfg.PathTemplate, track) + track.File = filepath.Base(relativePath) + if track.File == "." || track.File == string(filepath.Separator) { + track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) + relativePath = filepath.Dir(relativePath) + string(filepath.Separator) + track.File + slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) + } + dstFile = filepath.Join(destDir, relativePath) + } else { + if err = os.MkdirAll(destDir, os.ModePerm); err != nil { + return fmt.Errorf("couldn't make download directory: %s", err.Error()) + } + + dstFile = filepath.Join(destDir, track.File) + } + if err = os.MkdirAll(filepath.Dir(dstFile), os.ModePerm); err != nil { + return fmt.Errorf("couldn't make destination directory: %s", err.Error()) + } + + out, err := os.Create(dstFile) + if err != nil { + return fmt.Errorf("couldn't create destination file: %s", err.Error()) + } + + defer func() { + if cerr := out.Close(); cerr != nil { + slog.Error(fmt.Sprintf("failed to close destination file: %s", cerr.Error())) + } + }() + + if _, err = io.Copy(out, in); err != nil { + return fmt.Errorf("copy failed: %s", err.Error()) + } + + if err = out.Sync(); err != nil { + return fmt.Errorf("sync failed: %s", err.Error()) + } + + if c.Cfg.KeepPermissions { + info, err := os.Stat(srcFile) + if err != nil { + return fmt.Errorf("stat error: %s", err.Error()) + } + if err = os.Chmod(dstFile, info.Mode()); err != nil { + return fmt.Errorf("chmod failed: %s", err.Error()) + } + } + + if err = os.Remove(srcFile); err != nil { + return fmt.Errorf("failed to delete original file: %s", err.Error()) + } + + isEmpty, err := isDirEmpty(trackDir) + if err != nil { + return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) + } else if isEmpty { + if err = os.Remove(trackDir); err != nil { + return fmt.Errorf("failed to remove empty directory: %s", err.Error()) + } + } + + return nil +} \ No newline at end of file diff --git a/src/downloader/youtube.go b/src/downloader/youtube.go index f1497add..867be355 100644 --- a/src/downloader/youtube.go +++ b/src/downloader/youtube.go @@ -294,3 +294,8 @@ func (c *Youtube) GetDownloadStatus(tracks []*models.Track) (map[string]FileStat func (c *Youtube) Cleanup(track models.Track, ID string) error { return nil } + +func (c *Youtube) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { + + return nil +} diff --git a/src/main/main.go b/src/main/main.go index 25c196af..f060e0f8 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -35,7 +35,8 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro MainArtist string `json:"mainArtist"` Release string `json:"release"` CoverURL string `json:"coverUrl"` - CoverPath string `json:"coverPath"` + CoverPath string `json:"coverPath"` + MusicBrainzTrackID string `json:"mbTrackId,omitempty"` } type cacheFile struct { Tracks []cachedTrack `json:"tracks"` @@ -81,7 +82,8 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro MainArtist: mainArtist, Album: t.Release, CoverURL: t.CoverURL, - CoverPath: t.CoverPath, + CoverPath: t.CoverPath, + MusicBrainzTrackID: t.MusicBrainzTrackID, } } return tracks, name, nil @@ -175,6 +177,22 @@ func main() { tracks, playlistName, err = loadCustomTracks(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist) if err == nil { cfg.ClientCfg.PlaylistName = playlistName + hasMBIDs := func() bool { + for _, t := range tracks { + if t.MusicBrainzTrackID != "" { + return true + } + } + return false + }() + if cfg.DiscoveryCfg.Listenbrainz.EnrichTrackMetadata && hasMBIDs { + enriched, enrichErr := discovery.EnrichTracks(httpClient, cfg.DiscoveryCfg.Listenbrainz, tracks) + if enrichErr != nil { + slog.Warn("failed to enrich custom playlist metadata", "error", enrichErr) + } else { + tracks = enriched + } + } } } else { disc := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) diff --git a/src/models/types.go b/src/models/types.go index f3656baf..27999573 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -4,6 +4,7 @@ package models type Track struct { Album string + AlbumID string // Used by Lidarr AlbumArtist string ID string Artist string // All artists as returned by LB 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/playlist/playlist.go b/src/web/backend/playlist/playlist.go index 1847f9d2..c48ac782 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -31,7 +31,8 @@ type PlaylistTrack struct { Artist string MainArtist string Album string - CoverURL string + CoverURL string + MusicBrainzTrackID string } @@ -84,7 +85,8 @@ func modelTracksToPlaylistTracks(tracks []*models.Track) []PlaylistTrack { Artist: t.Artist, MainArtist: t.MainArtist, Album: t.Album, - CoverURL: t.CoverURL, + CoverURL: t.CoverURL, + MusicBrainzTrackID: t.MusicBrainzTrackID, } } return out @@ -167,7 +169,8 @@ type cachedPrefetchTrack struct { MainArtist string `json:"mainArtist,omitempty"` Release string `json:"release"` CoverURL string `json:"coverUrl,omitempty"` - CoverPath string `json:"coverPath,omitempty"` + CoverPath string `json:"coverPath,omitempty"` + MusicBrainzTrackID string `json:"mbTrackId,omitempty"` } // writePreliminaryCache writes the track cache with remote cover URLs immediately. @@ -175,7 +178,7 @@ type cachedPrefetchTrack struct { func writePreliminaryCache(cfgDir, playlistType string, tracks []PlaylistTrack) bool { ct := make([]cachedPrefetchTrack, len(tracks)) for i, t := range tracks { - ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: t.CoverURL} + ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: t.CoverURL, MusicBrainzTrackID: t.MusicBrainzTrackID} } if !writeTrackCache(cfgDir, playlistType, ct) { return false @@ -195,7 +198,7 @@ func downloadAndCacheCovers(cfgDir, playlistType string, tracks []PlaylistTrack) ct := make([]cachedPrefetchTrack, len(tracks)) for i, t := range tracks { APIPath, coverPath := util.DownloadCover(t.CoverURL, coversDir) - ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath} + ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath, MusicBrainzTrackID: t.MusicBrainzTrackID} } if writeTrackCache(cfgDir, playlistType, ct) { slog.Info("prefetch: cache updated", "playlist", playlistType, "covers", "local") diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 6ca56f93..8b60c05a 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -314,6 +314,8 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { FilterList string `json:"filter_list"` SlskdURL string `json:"slskd_url"` SlskdAPIKey string `json:"slskd_api_key"` + LidarrURL string `json:"lidarr_url"` + LidarrAPIKey string `json:"lidarr_api_key"` Extensions string `json:"extensions"` // slskd } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -344,6 +346,8 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { "FILTER_LIST": body.FilterList, "SLSKD_URL": body.SlskdURL, "SLSKD_API_KEY": body.SlskdAPIKey, + "LIDARR_URL": body.LidarrURL, + "LIDARR_API_KEY": body.LidarrAPIKey, "EXTENSIONS": body.Extensions, // slskd "WIZARD_COMPLETE": "true", } 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)