Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f3db69c
Implement initial support for Lidarr downloader
avedor Apr 30, 2025
3353afc
Use main artist in search
avedor May 4, 2025
11e10e3
Use raw api calls
avedor May 5, 2025
e616b0d
DRY downloader checker
avedor Jul 22, 2025
cb7ec3f
Update gui to include lidarr
avedor Jun 4, 2026
f2b0ebd
Don't add an album that exists
avedor Jun 5, 2026
2e4cb79
check trackID when monitoring; check Lidarr history for completed dow…
LumePart Jul 7, 2026
9c9b356
use queueID when deleting lidarr downloads
LumePart Jul 7, 2026
3047fce
implement checking search status
LumePart Jul 7, 2026
0cf43b6
change lidarr config values
LumePart Jul 7, 2026
e1d3c84
use helper; handle edge-cases
LumePart Jul 7, 2026
0a728cc
add pagination and some fixes
LumePart Jul 7, 2026
bb7e041
replace print with slog
LumePart Jul 7, 2026
82b6c52
add rootfolder search by name
LumePart Jul 7, 2026
33c1ca5
wire LidarrURL + LidarrAPIKey to wizard handler, persist MBID's throu…
dammitjeff Jul 13, 2026
d169499
add ForeignArtistID to Album struct and update getReleaseGroupId logic
dammitjeff Jul 13, 2026
e928e58
Merge pull request #1 from dammitjeff/lidarr-frontend-fixes
avedor Jul 23, 2026
890fb8a
save root dir when initializing lidarr
LumePart Jul 31, 2026
96e8632
Track ID based lidarr monitoring
LumePart Jul 31, 2026
43db414
create album cache
LumePart Jul 31, 2026
7013c14
cache tracks from lidarr response
LumePart Jul 31, 2026
3176aa0
split GetTrack to subfunctions
LumePart Jul 31, 2026
1675141
check cache before searching track
LumePart Jul 31, 2026
e922f88
add pointe
LumePart Jul 31, 2026
6964664
move MoveDownload under monitor struct
LumePart Jul 31, 2026
fc10400
remove MoveDownload from downloader
LumePart Jul 31, 2026
373ca09
remove comparison
LumePart Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
79 changes: 57 additions & 22 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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()
}

Expand All @@ -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)
}

Expand All @@ -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")
}
Expand All @@ -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
Expand Down
23 changes: 15 additions & 8 deletions src/discovery/listenbrainz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}(),
}
}
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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{
Expand All @@ -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,
})
}
Expand Down
102 changes: 10 additions & 92 deletions src/downloader/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading