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 874e56a6..4f0a1f19 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -93,14 +93,15 @@ type SubsonicConfig struct {
}
type DownloadConfig struct {
- DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
- PathTemplate string `env:"PATH_TEMPLATE"`
+ DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
+ PathTemplate string `env:"PATH_TEMPLATE"`
Youtube Youtube
YoutubeMusic YoutubeMusic
Slskd Slskd
+ Lidarr Lidarr
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"`
@@ -134,26 +135,52 @@ 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
+ MaxDuration int `env:"SLSKD_MONITOR_MAX_DURATION" env-default:"120"` // 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 album/track search status before skipping the track
+ LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"`
+ MigrateDL bool `env:"LIDARR_MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from LidarrDir to DownloadDir
+ Timeout int `env:"LIDARR_TIMEOUT" env-default:"20"`
+ RootFolder string `env:"LIDARR_ROOT_FOLDER" env-default:"Music"` // Root folder name to use in Lidarr
+ PathTemplate string `env:"PATH_TEMPLATE"`
+ KeepPermissions bool `env:"LIDARR_KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download
+ Filters Filters
+ MonitorConfig LidarrMon
+}
+
+type LidarrMon struct {
+ Interval int `env:"LIDARR_MONITOR_INTERVAL" env-default:"1"` // in minutes
+ Duration int `env:"LIDARR_MONITOR_DURATION" env-default:"20"` // in minutes
+ MaxDuration int `env:"LIDARR_MONITOR_MAX_DURATION" env-default:"120"` // in minutes
}
type DiscoveryConfig struct {
- 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"`
@@ -223,6 +250,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()
}
@@ -231,6 +259,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)
}
@@ -252,11 +281,9 @@ func fixBaseURL(rawURL string) string {
return strings.TrimRight(u, "/")
}
-func (cfg *Config) HandleDeprecation() { //
- if cfg.Debug {
- slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead")
- cfg.LogLevel = "DEBUG"
- }
+
+// deprecation logs and remappings
+func (cfg *Config) HandleDeprecation() {
if cfg.Flags.PersistSet {
slog.Warn("--persist has been deprecated since v1.1.3, please use --replace-playlist")
}
@@ -267,6 +294,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 ddb25df8..993b5c2d 100644
--- a/src/discovery/listenbrainz.go
+++ b/src/discovery/listenbrainz.go
@@ -37,7 +37,7 @@ type Metadata struct {
Artist struct {
ArtistCreditID int `json:"artist_credit_id"`
Artists []struct {
- ArtistMbid string `json:"artist_mbid"`
+ MusicBrainzArtistID string `json:"artist_mbid"`
BeginYear int `json:"begin_year"`
EndYear int `json:"end_year,omitempty"`
JoinPhrase string `json:"join_phrase"`
@@ -121,7 +121,7 @@ type Exploration struct {
AdditionalMetadata struct {
Artists []struct {
ArtistCreditName string `json:"artist_credit_name"`
- ArtistMbid string `json:"artist_mbid"`
+ MusicBrainzArtistID string `json:"artist_mbid"`
JoinPhrase string `json:"join_phrase"`
} `json:"artists"`
CaaID int64 `json:"caa_id"`
@@ -383,7 +383,7 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T
MusicBrainzTrackID: mbTrackID,
MusicBrainzAlbumID: rel.CaaReleaseMbid,
MusicBrainzReleaseGroupID: rel.ReleaseGroupMbid,
- MusicBrainzArtistID: recArtists[0].ArtistMbid,
+ MusicBrainzArtistID: recArtists[0].MusicBrainzArtistID,
})
}
@@ -432,7 +432,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) (
mainArtist := recording.Artist.Name
mainArtistID := ""
if len(recording.Artist.Artists) > 0 {
- mainArtistID = recording.Artist.Artists[0].ArtistMbid
+ mainArtistID = recording.Artist.Artists[0].MusicBrainzArtistID
}
recArtists := recording.Artist.Artists
@@ -581,7 +581,7 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) (
if len(recArtists) == 0 {
return ""
}
- return recArtists[0].ArtistMbid
+ return recArtists[0].MusicBrainzArtistID
}(),
}
}
@@ -658,6 +658,13 @@ func FetchTopRecordings(httpClient *util.HttpClient, user string) ([]*models.Tra
return lb.getTopRecordings(user)
}
+// EnrichTracks fetches MusicBrainz metadata for tracks that have recording MBIDs,
+// populating fields like MusicBrainzReleaseGroupID and MusicBrainzArtistID.
+func EnrichTracks(httpClient *util.HttpClient, lbCfg cfg.Listenbrainz, tracks []*models.Track) ([]*models.Track, error) {
+ lb := &ListenBrainz{HttpClient: httpClient, cfg: lbCfg}
+ return lb.enrichTracks(tracks, lbCfg.SingleArtist)
+}
+
// FetchMostRecentPlaylistByType finds and fetches the most recent LB-generated playlist of the given type for the user.
func FetchMostRecentPlaylistByType(httpClient *util.HttpClient, user, playlistType string) ([]*models.Track, error) {
lb := &ListenBrainz{HttpClient: httpClient, cfg: cfg.Listenbrainz{ImportPlaylist: playlistType}}
@@ -718,9 +725,9 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (stri
recordingMBID = parts[len(parts)-1]
}
- artistMBID := ""
+ MusicBrainzArtistID := ""
if len(trackMeta.Artists) > 0 {
- artistMBID = trackMeta.Artists[0].ArtistMbid
+ MusicBrainzArtistID = trackMeta.Artists[0].MusicBrainzArtistID
}
tracks = append(tracks, &models.Track{
@@ -732,7 +739,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..47921656 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",
@@ -365,6 +283,97 @@ func tempAudioFile(path string) string {
return strings.TrimSuffix(path, ext) + ".tmp" + ext
}
+
+func moveTrack(srcFile, destDir string, track *models.Track, pathTemplate string, keepPerms bool) error {
+ var dstFile string
+ if pathTemplate != "" {
+ relativePath := buildTrackPath(pathTemplate, track)
+ track.File = filepath.Base(relativePath)
+
+ if track.File == "." || track.File == string(filepath.Separator) {
+ track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(srcFile)
+ relativePath = filepath.Join(filepath.Dir(relativePath), track.File)
+ slog.Warn("invalid path template result",
+ "track", track.Title,
+ "artist", track.Artist,
+ "filename", track.File)
+ }
+
+ dstFile = filepath.Join(destDir, relativePath)
+ } else {
+ if err := os.MkdirAll(destDir, os.ModePerm); err != nil {
+ return err
+ }
+ dstFile = filepath.Join(destDir, track.File)
+ }
+
+ if err := moveFile(srcFile, dstFile, keepPerms); err != nil {
+ return err
+ }
+
+ srcDir := filepath.Dir(srcFile)
+ isEmpty, err := isDirEmpty(srcDir)
+ if err != nil {
+ return fmt.Errorf("couldn't check if directory is empty: %s", err.Error())
+ } else if isEmpty {
+ if err = os.Remove(srcDir); err != nil {
+ return fmt.Errorf("failed to remove empty directory: %s", err.Error())
+ }
+ }
+
+ return nil
+}
+
+func moveFile(srcFile, dstFile string, keepPermissions bool) error {
+ in, err := os.Open(srcFile)
+ if err != nil {
+ return fmt.Errorf("couldn't open source file: %w", err)
+ }
+ defer func() {
+ if err := in.Close(); err != nil {
+ slog.Warn("failed to close source file", "err", err)
+ }
+ }()
+ if err := os.MkdirAll(filepath.Dir(dstFile), 0755); err != nil {
+ return fmt.Errorf("couldn't create directory for file: %w", err)
+ }
+ out, err := os.Create(dstFile)
+ if err != nil {
+ return fmt.Errorf("couldn't create destination file: %w", err)
+ }
+
+ if _, err := io.Copy(out, in); err != nil {
+ if closeErr := out.Close(); closeErr != nil {
+ slog.Warn("failed to close destination file", "err", closeErr)
+ }
+ return fmt.Errorf("copy failed: %w", err)
+ }
+
+ if err := out.Sync(); err != nil {
+ if closeErr := out.Close(); closeErr != nil {
+ slog.Warn("failed to close destination file", "err", closeErr)
+ }
+ return fmt.Errorf("sync failed: %w", err)
+ }
+
+ if err := out.Close(); err != nil {
+ return fmt.Errorf("failed to close destination file: %w", err)
+ }
+
+ if keepPermissions {
+ info, err := os.Stat(srcFile)
+ if err != nil {
+ return fmt.Errorf("stat error: %w", err)
+ }
+
+ if err := os.Chmod(dstFile, info.Mode()); err != nil {
+ return fmt.Errorf("chmod failed: %w", err)
+ }
+ }
+
+ return os.Remove(srcFile)
+}
+
func isDirEmpty(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go
new file mode 100644
index 00000000..0e5640b4
--- /dev/null
+++ b/src/downloader/lidarr.go
@@ -0,0 +1,853 @@
+package downloader
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "maps"
+ "os"
+ "path/filepath"
+ "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
+
+ MainArtist string
+ Name string
+ ReleaseYear int
+ ReleaseGroupMBID string
+ ArtistMBID string
+ Moved bool
+
+ Tracks func() []LidarrTrack
+ Files map[int]AlbumFile
+}
+
+type AlbumFile struct {
+ Path string
+ Track LidarrTrack
+}
+
+type LidarrAlbum struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Disambiguation string `json:"disambiguation"`
+ ArtistID int `json:"artistId"`
+ ForeignAlbumID string `json:"foreignAlbumId"`
+ Artist LidarrAlbumArtist `json:"artist"`
+ ReleaseDate time.Time `json:"releaseDate"`
+ Releases []LidarrReleases `json:"releases"`
+}
+
+type LidarrAlbumArtist struct {
+ ForeignArtistID string `json:"foreignArtistId"`
+ ArtistName string `json:"artistName"`
+
+}
+
+type LidarrReleases struct {
+ ID int `json:"id"`
+ AlbumID int `json:"albumId"`
+ ForeignReleaseID string `json:"foreignReleaseId"`
+ Title string `json:"title"`
+ Status string `json:"status"`
+ Duration int `json:"duration"`
+ TrackCount int `json:"trackCount"`
+ MediumCount int `json:"mediumCount"`
+ Disambiguation string `json:"disambiguation"`
+ Country []string `json:"country"`
+ Label []string `json:"label"`
+ Format string `json:"format"`
+}
+
+type LidarrTrack struct {
+ 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,
+ AlbumCache: make(map[string]*AlbumMetadata),
+ }
+}
+
+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,
+ MaxDuration: time.Duration(c.Cfg.MonitorConfig.MaxDuration) * 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 []LidarrAlbum
+ 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)
+ track.OriginalYear = libraryAlbum.ReleaseDate.Year()
+
+ c.cacheAlbum(track)
+
+ slog.Info("album found in Lidarr library", "album", libraryAlbum.Title, "id", libraryAlbum.ID)
+ if err := c.findTrackID(track); err != nil {
+ return fmt.Errorf("failed to get track ID: %w", err)
+ }
+ return nil
+}
+
+func (c *Lidarr) GetTrack(track *models.Track) error {
+
+ if track.Present {
+ return nil
+ }
+
+ slog.Info("downloading track",
+ "title", track.Title,
+ "artist", track.Artist,
+ "album", track.Album,
+ "albumID", track.AlbumID,
+ )
+
+ if c.populateFromCache(track) {
+ if err := c.findTrackID(track); err == nil && track.Present {
+ return nil // track exists
+ }
+ }
+
+ 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 LidarrAlbum
+ if err = util.ParseResp(body, &album); err != nil {
+ return fmt.Errorf("failed to unmarshal lidarr album: %w", err)
+ }
+ track.AlbumID = strconv.Itoa(album.ID)
+ track.MainArtistID = strconv.Itoa(album.ArtistID)
+ track.OriginalYear = album.ReleaseDate.Year()
+ c.cacheAlbum(track)
+ if err := c.searchStatus(album.ID, 1); err != nil {
+ return fmt.Errorf("album search failed: %w", err)
+ }
+ 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 <= c.Cfg.Retry; 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 && len(tracks) != 0 {
+ return tracks
+ } else if len(tracks) == 0 {
+ err = fmt.Errorf("no tracks loaded after retries")
+ }
+ }
+ slog.Debug("no album tracks loaded", "albumID", albumID, "artistID", artistID, "attempt", attempt, "maxAttempts", c.Cfg.Retry)
+ if attempt == c.Cfg.Retry {
+ slog.Error("failed loading album tracks", "albumID", albumID, "artistID", artistID, "err", err)
+ }
+ if attempt < c.Cfg.Retry {
+ time.Sleep(time.Second * 15)
+ }
+ }
+ 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.MusicBrainzTrackID = t.ForeignRecordingID
+ track.MusicBrainzReleaseTrackID = t.ForeignTrackID
+
+ 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)
+}
+
+const invalidAlbumID = "invalid"
+func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) {
+
+ albums := make(map[string][]*models.Track)
+
+ for _, track := range tracks {
+ if track.ID == "" && track.AlbumID != invalidAlbumID {
+ // check the track cache for potentially missed tracks
+ if err := c.findTrackID(track); err != nil {
+ track.AlbumID = invalidAlbumID
+ }
+ }
+ if track.ID == "" || track.AlbumID == "" || track.AlbumID == invalidAlbumID || track.Present {
+ continue
+ }
+ albums[track.AlbumID] = append(albums[track.AlbumID], track)
+ }
+ 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)
+ }
+
+
+ cache := c.AlbumCache[c.cacheKey(albumTracks[0])]
+ for _, r := range history.Records {
+ if r.Data.ImportedPath == "" {
+ continue
+ }
+ historyTrackID := strconv.Itoa(r.TrackID)
+
+ c.CacheMu.Lock()
+ cache.Files[r.TrackID] = AlbumFile{
+ Path: r.Data.ImportedPath,
+ Track: r.Track}
+ c.CacheMu.Unlock()
+
+ for _, track := range albumTracks {
+ if _, exists := statuses[track.ID]; exists {
+ continue
+ }
+
+ trackIDMatch := track.ID == historyTrackID
+
+ mbIDMatch :=
+ (track.MusicBrainzTrackID != "" &&
+ track.MusicBrainzTrackID == r.Track.ForeignRecordingID) ||
+ (track.MusicBrainzReleaseTrackID != "" &&
+ track.MusicBrainzReleaseTrackID == r.Track.ForeignTrackID)
+
+ if (trackIDMatch || mbIDMatch) {
+ statuses[track.ID] = FileStatus{
+ ID: track.ID,
+ Filename: r.Data.ImportedPath,
+ State: "Succeeded",
+ BytesTransferred: 1,
+ BytesRemaining: 0,
+ PercentComplete: 100,
+ QueueID: "",
+ }
+ }
+ }
+ }
+
+ 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&includeTrack=true&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
+ }
+ }
+
+ slog.Warn("specified root folder not found, please create one in Lidarr or point explo to a correct folder, falling back to first available folder", "expectedRootFolder", c.Cfg.RootFolder)
+ c.RootFolder = rootFolders[0]
+
+ return nil
+}
+
+func (c *Lidarr) getReleaseGroupId(track *models.Track) error {
+ 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 []LidarrAlbum
+ if err = util.ParseResp(body, &albums); err != nil {
+ return fmt.Errorf("failed to unmarshal lookup response: %w", err)
+ }
+
+ releaseMBID, artistMBID := c.filterAlbumSearch(track.Album, track.MainArtist, albums)
+ if releaseMBID == "" || artistMBID == "" {
+ return fmt.Errorf("could not find album for track: %s - %s", track.Title, track.MainArtist)
+ }
+
+ track.MusicBrainzReleaseGroupID = releaseMBID
+ track.MusicBrainzArtistID = artistMBID
+ return nil
+}
+
+func (c *Lidarr) filterAlbumSearch(albumName, mainArtist string, albums []LidarrAlbum) (string, string) {
+ cleanAlbum := util.AlnumOnly(albumName)
+ cleanArtist := util.AlnumOnly(mainArtist)
+
+ bestScore := -1
+ var bestAlbum *LidarrAlbum
+ for i := range albums {
+ album := &albums[i]
+
+ cleanAlbumTitle := util.AlnumOnly(album.Title)
+ cleanArtistName := util.AlnumOnly(album.Artist.ArtistName)
+ albumMatch := util.ContainsFold(cleanAlbumTitle, cleanAlbum) || util.ContainsFold(cleanAlbum, cleanAlbumTitle)
+ artistMatch := util.ContainsFold(cleanArtistName, cleanArtist) || util.ContainsFold(cleanArtist, cleanArtistName)
+
+ if !albumMatch || !artistMatch {
+ continue
+ }
+ albumScore := 0
+ for _, release := range album.Releases {
+ score := 0
+ if release.Format == "Digital Media" {
+ score++
+ }
+ if slices.Contains(release.Country, "[Worldwide]") {
+ score++
+ }
+ if strings.EqualFold(release.Status, "official") {
+ score++
+ }
+
+ if score > albumScore {
+
+ albumScore = score
+ }
+ }
+ if albumScore > bestScore {
+ bestScore = albumScore
+ bestAlbum = album
+ }
+
+ }
+
+ if bestAlbum != nil {
+ return bestAlbum.ForeignAlbumID, bestAlbum.Artist.ForeignArtistID
+ }
+ return "", ""
+}
+
+func percent(total, remaining int64) float64 {
+ if total == 0 {
+ return 0
+ }
+ 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("[lidarr] failed to delete download", "err", 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,
+ Name: track.Album,
+ MainArtist: track.MainArtist,
+ AlbumID: track.AlbumID,
+ ArtistID: track.MainArtistID,
+ ReleaseYear: track.OriginalYear,
+
+ Tracks: sync.OnceValue(func() []LidarrTrack {
+ return c.getAlbumTracks(track.AlbumID, track.MainArtistID)
+ }),
+ Files: make(map[int]AlbumFile),
+ }
+}
+// Checks if album is cached. Populates track fields if it is
+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, albumPath string, track *models.Track) error {
+
+ key := c.cacheKey(track)
+ c.CacheMu.RLock()
+ album := c.AlbumCache[key]
+ if album == nil {
+ c.CacheMu.RUnlock()
+ return fmt.Errorf("album not cached")
+ }
+ if album.Moved {
+ c.CacheMu.RUnlock()
+ return nil
+ }
+
+ albumFiles := maps.Clone(album.Files)
+ albumMeta := *album
+ c.CacheMu.RUnlock()
+ if len(albumFiles) == 0 {
+ return fmt.Errorf("no downloaded files found for album")
+ }
+
+ slog.Info("moving album", "artist", track.MainArtist, "album", track.Album, "id", track.AlbumID)
+ var srcFile string
+ for _, file := range albumFiles {
+ relPath, err := filepath.Rel(c.RootFolder.Path, file.Path)
+ if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) {
+ slog.Warn("track path does not belong to Lidarr RootFolder, skipping", "trackPath", file.Path, "rootFolder", c.RootFolder.Path)
+ continue
+ }
+
+ srcFile = filepath.Join(srcDir, relPath)
+
+ albumTrack := c.addMetadatafromCache(albumMeta, file)
+
+ if err := moveTrack(srcFile, destDir, &albumTrack, c.Cfg.PathTemplate, c.Cfg.KeepPermissions); err != nil {
+ return fmt.Errorf("failed to move track: %w", err)
+ }
+ }
+ c.CacheMu.Lock()
+ album.Moved = true
+ c.CacheMu.Unlock()
+
+ return nil
+}
+
+
+// Add metadata to album tracks from cache to use in path templating
+func (c *Lidarr) addMetadatafromCache(album AlbumMetadata, file AlbumFile) models.Track {
+
+ return models.Track {
+ CleanTitle: file.Track.Title,
+ Album: album.Name,
+ MainArtist: album.MainArtist,
+ OriginalYear: album.ReleaseYear,
+ TrackNumber: file.Track.AbsoluteTrackNumber,
+ DiscNumber: file.Track.MediumNumber,
+ File: filepath.Base(file.Path),
+ }
+}
\ No newline at end of file
diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go
index 38a9d7d5..b5177d4b 100644
--- a/src/downloader/monitor.go
+++ b/src/downloader/monitor.go
@@ -12,12 +12,14 @@ 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
}
type MonitorConfig struct {
CheckInterval time.Duration
MonitorDuration time.Duration
+ MaxDuration time.Duration
MigrateDownload bool
FromDir string
ToDir string
@@ -32,6 +34,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 {
@@ -44,6 +47,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err
}
ticker := time.NewTicker(monCfg.CheckInterval)
+
defer ticker.Stop()
for range ticker.C {
@@ -51,12 +55,13 @@ 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()
for _, track := range tracks {
- key := fmt.Sprintf("%s|%s", track.ID, track.File)
+ key := fmt.Sprintf("%s|%s", track.ID, track.CleanTitle)
if track.Present || track.ID == "" || (progressMap[key] != nil && progressMap[key].Skipped) {
continue
@@ -68,36 +73,40 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err
LastBytesTransferred: 0,
Counter: 0,
LastUpdated: currentTime,
+ StartedAt: currentTime,
}
}
- fileStatus, exists := statuses[track.File]
+ fileStatus, exists := statuses[track.ID]
tracker := progressMap[key]
if !exists {
tracker.Counter++
-
if tracker.Counter >= 2 {
slog.Info("[monitor] track not found in queue after retries, skipping", "service", monCfg.Service,"track title", track.CleanTitle, "track artist", track.MainArtist)
tracker.Skipped = true
}
continue
- }
- monitoredTime := currentTime.Sub(tracker.LastUpdated)
+ }
+ tracker.Counter = 0
- if fileStatus.BytesRemaining == 0 || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") {
+ stallTime := currentTime.Sub(tracker.LastUpdated)
+ monitoredTime := currentTime.Sub(tracker.StartedAt)
+
+ if (fileStatus.BytesRemaining == 0 && fileStatus.BytesTransferred != 0) || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") {
+ track.File = fileStatus.Filename
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 {
- slog.Error("error while moving file", "err", err.Error())
+ if err = m.MoveDownload(monCfg.FromDir, monCfg.ToDir, filePath, track); err != nil {
+ slog.Error("error while moving file", "err", err)
} else {
slog.Info("track moved successfully", "service", monCfg.Service)
}
}
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
@@ -105,13 +114,35 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err
} else if fileStatus.BytesTransferred > tracker.LastBytesTransferred {
tracker.LastBytesTransferred = fileStatus.BytesTransferred
tracker.LastUpdated = currentTime
- slog.Info("[monitor] progress updated", "service", monCfg.Service, "file", track.File, "bytes transferred", fileStatus.BytesTransferred)
+ slog.Info("[monitor] progress updated", "service", monCfg.Service, "title", track.CleanTitle, "bytes transferred", fileStatus.BytesTransferred)
continue
- } else if monitoredTime > monCfg.MonitorDuration || fileStatus.State == "Errored" {
- slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "file", track.File, "state", fileStatus.State, "duration", monitoredTime,)
+ } else if fileStatus.State == "Errored" ||
+ stallTime > monCfg.MonitorDuration ||
+ monitoredTime > monCfg.MaxDuration {
+
+ switch {
+ case fileStatus.State == "Errored":
+ slog.Info("[monitor] download errored",
+ "service", monCfg.Service,
+ "title", track.CleanTitle,
+ )
+ case stallTime > monCfg.MonitorDuration:
+ slog.Info("[monitor] download stalled",
+ "service", monCfg.Service,
+ "title", track.CleanTitle,
+ "duration", stallTime,
+ )
+ default:
+ slog.Info("[monitor] maximum monitor time exceeded",
+ "service", monCfg.Service,
+ "title", track.CleanTitle,
+ "duration", monitoredTime,
+ )
+ }
+
tracker.Skipped = true
- if err = m.Cleanup(*track, fileStatus.ID); err != nil {
+ if err = m.Cleanup(*track, fileStatus.QueueID); err != nil {
slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error()))
}
continue
@@ -129,10 +160,10 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err
// Checks if all tracks are processed (either downloaded or skipped)
func tracksProcessed(tracks []*models.Track, progressMap map[string]*DownloadMonitor) bool {
for _, track := range tracks {
- key := fmt.Sprintf("%s|%s", track.ID, track.File)
+ key := fmt.Sprintf("%s|%s", track.ID, track.CleanTitle)
tracker, exists := progressMap[key]
if !track.Present && exists && !tracker.Skipped {
- slog.Info("[monitor] track download still in progress", "title", track.CleanTitle, "artist", track.MainArtist, "file", track.File)
+ slog.Info("[monitor] track download still in progress", "title", track.CleanTitle, "artist", track.MainArtist)
return false
}
}
diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go
index 0708786f..38e6e1e2 100644
--- a/src/downloader/slskd.go
+++ b/src/downloader/slskd.go
@@ -92,6 +92,7 @@ type DownloadMonitor struct {
PlaceInQueue int
Skipped bool
LastUpdated time.Time
+ StartedAt time.Time
}
type Slskd struct {
@@ -123,6 +124,7 @@ func (c *Slskd) GetConf() (MonitorConfig, error) {
return MonitorConfig{
CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute,
MonitorDuration: time.Duration(c.Cfg.MonitorConfig.Duration) * time.Minute,
+ MaxDuration: time.Duration(c.Cfg.MonitorConfig.MaxDuration) * time.Minute,
MigrateDownload: c.Cfg.MigrateDL,
ToDir: c.DownloadDir,
FromDir: c.Cfg.SlskdDir,
@@ -161,7 +163,7 @@ func (c *Slskd) QueryTrack(track *models.Track) error {
if err != nil {
cleanup()
- return err
+ return fmt.Errorf("%w: %s", err, trackDetails)
}
if !completed {
@@ -398,13 +400,15 @@ func (c *Slskd) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus
for _, dir := range status.Directories {
for _, file := range dir.Files {
if string(file.Name) == track.File {
- fileStatuses[track.File] = FileStatus{
+ fileStatuses[track.ID] = FileStatus{
ID: file.ID,
Size: file.Size,
State: normalize(file.State),
+ Filename: file.Name,
BytesTransferred: file.BytesTransferred,
BytesRemaining: file.BytesRemaining,
PercentComplete: file.PercentComplete,
+ QueueID: file.ID,
}
}
}
@@ -487,3 +491,24 @@ func normalize(state string) string{
}
return state
}
+
+
+func (c *Slskd) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error {
+ trackDir := filepath.Join(srcDir, trackPath)
+
+ if c.Cfg.RenameTrack { // Rename file to {title}-{artist} format
+ track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File)
+ }
+ srcFile := filepath.Join(trackDir, track.File)
+ if c.Cfg.OverwriteMetadata {
+ metadata := util.BuildffmpegMetadata(*track)
+ if err := overwriteMetadata(metadata, srcFile); err != nil {
+ slog.Warn("problem overwriting metadata", "msg", err.Error())
+ }
+ }
+ if err := moveTrack(srcFile, destDir, track, c.Cfg.PathTemplate, c.Cfg.KeepPermissions); err != nil {
+ return fmt.Errorf("failed to move track: %w", err)
+ }
+
+ return nil
+}
\ No newline at end of file
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 6b32e5e3..19f9823f 100644
--- a/src/main/main.go
+++ b/src/main/main.go
@@ -35,7 +35,8 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro
MainArtist string `json:"mainArtist"`
Release string `json:"release"`
CoverURL string `json:"coverUrl"`
- CoverPath string `json:"coverPath"`
+ CoverPath string `json:"coverPath"`
+ MusicBrainzTrackID string `json:"mbTrackId,omitempty"`
}
type cacheFile struct {
Tracks []cachedTrack `json:"tracks"`
@@ -81,7 +82,8 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro
MainArtist: mainArtist,
Album: t.Release,
CoverURL: t.CoverURL,
- CoverPath: t.CoverPath,
+ CoverPath: t.CoverPath,
+ MusicBrainzTrackID: t.MusicBrainzTrackID,
}
}
return tracks, name, nil
@@ -174,6 +176,22 @@ func main() {
tracks, playlistName, err = loadCustomTracks(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist)
if err == nil {
cfg.ClientCfg.PlaylistName = playlistName
+ hasMBIDs := func() bool {
+ for _, t := range tracks {
+ if t.MusicBrainzTrackID != "" {
+ return true
+ }
+ }
+ return false
+ }()
+ if cfg.DiscoveryCfg.Listenbrainz.EnrichTrackMetadata && hasMBIDs {
+ enriched, enrichErr := discovery.EnrichTracks(httpClient, cfg.DiscoveryCfg.Listenbrainz, tracks)
+ if enrichErr != nil {
+ slog.Warn("failed to enrich custom playlist metadata", "error", enrichErr)
+ } else {
+ tracks = enriched
+ }
+ }
}
} else {
disc := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient)
diff --git a/src/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..6a3c289d 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.
@@ -180,6 +193,7 @@ var AllConfigKeys = []string{
"SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST",
"DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA",
"DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST",
- "SLSKD_URL", "SLSKD_API_KEY",
- "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN",
-}
\ No newline at end of file
+ "SLSKD_URL", "SLSKD_API_KEY", "SLSKD_MIGRATE_DOWNLOADS",
+ "LIDARR_URL", "LIDARR_API_KEY", "LIDARR_MIGRATE_DOWNLOADS", "LIDARR_ROOT_FOLDER",
+ "WIZARD_COMPLETE", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN",
+}
diff --git a/src/web/backend/playlist/playlist.go b/src/web/backend/playlist/playlist.go
index 88b26181..d1cd494d 100644
--- a/src/web/backend/playlist/playlist.go
+++ b/src/web/backend/playlist/playlist.go
@@ -31,7 +31,8 @@ type PlaylistTrack struct {
Artist string
MainArtist string
Album string
- CoverURL string
+ CoverURL string
+ MusicBrainzTrackID string
}
@@ -86,7 +87,8 @@ func modelTracksToPlaylistTracks(tracks []*models.Track) []PlaylistTrack {
Artist: t.Artist,
MainArtist: t.MainArtist,
Album: t.Album,
- CoverURL: t.CoverURL,
+ CoverURL: t.CoverURL,
+ MusicBrainzTrackID: t.MusicBrainzTrackID,
}
}
return out
@@ -169,7 +171,8 @@ type cachedPrefetchTrack struct {
MainArtist string `json:"mainArtist,omitempty"`
Release string `json:"release"`
CoverURL string `json:"coverUrl,omitempty"`
- CoverPath string `json:"coverPath,omitempty"`
+ CoverPath string `json:"coverPath,omitempty"`
+ MusicBrainzTrackID string `json:"mbTrackId,omitempty"`
}
// writePreliminaryCache writes the track cache with remote cover URLs immediately.
@@ -177,7 +180,7 @@ type cachedPrefetchTrack struct {
func writePreliminaryCache(cfgDir, playlistType string, tracks []PlaylistTrack) bool {
ct := make([]cachedPrefetchTrack, len(tracks))
for i, t := range tracks {
- ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: t.CoverURL}
+ ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: t.CoverURL, MusicBrainzTrackID: t.MusicBrainzTrackID}
}
if !writeTrackCache(cfgDir, playlistType, ct) {
return false
@@ -197,7 +200,7 @@ func downloadAndCacheCovers(cfgDir, playlistType string, tracks []PlaylistTrack)
ct := make([]cachedPrefetchTrack, len(tracks))
for i, t := range tracks {
APIPath, coverPath := util.DownloadCover(t.CoverURL, coversDir)
- ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath}
+ ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath, MusicBrainzTrackID: t.MusicBrainzTrackID}
}
if writeTrackCache(cfgDir, playlistType, ct) {
slog.Info("prefetch: cache updated", "playlist", playlistType, "covers", "local")
diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go
index 513baf27..43ceb36d 100644
--- a/src/web/backend/settings/handlers.go
+++ b/src/web/backend/settings/handlers.go
@@ -438,6 +438,8 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) {
FilterList string `json:"filter_list"`
SlskdURL string `json:"slskd_url"`
SlskdAPIKey string `json:"slskd_api_key"`
+ LidarrURL string `json:"lidarr_url"`
+ LidarrAPIKey string `json:"lidarr_api_key"`
Extensions string `json:"extensions"` // slskd
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -468,6 +470,8 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) {
"FILTER_LIST": body.FilterList,
"SLSKD_URL": body.SlskdURL,
"SLSKD_API_KEY": body.SlskdAPIKey,
+ "LIDARR_URL": body.LidarrURL,
+ "LIDARR_API_KEY": body.LidarrAPIKey,
"EXTENSIONS": body.Extensions, // slskd
"WIZARD_COMPLETE": "true",
}
diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx
index 2744d8ca..5f0b61c8 100644
--- a/src/web/frontend/src/components/Wizard.jsx
+++ b/src/web/frontend/src/components/Wizard.jsx
@@ -465,20 +465,24 @@ 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,
+ slskdMigrateDownloads,
dlServices,
youtubeApiKey,
trackExtension,
filterList,
slskdUrl,
slskdApiKey,
+ lidarrUrl,
+ lidarrApiKey,
+ lidarrMigrateDownloads,
+ lidarrRootFolder,
extensions,
} = fields;
const isLocked = (key) => envSources[key] === "env";
@@ -487,6 +491,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;
};
@@ -526,7 +532,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) {
placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} />
+ By default, lidarr saves tracks to whichever download path is configured in your Root Folder. +
++ Alternative: Create a dedicated Root Folder for Explo in Lidarr and define it above. Lidarr will handle organizing downloads itself, allowing you to leave "Move completed downloads" disabled. +
+