Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ YOUTUBE_API_KEY=
# SINGLE_ARTIST=true
# Playlist name format: week (Weekly-Exploration-2026-Week5) or date (Weekly-Exploration-2026-01-31)
# PLAYLISTNAME_FORMAT=week
# Overwrite track metadata with metadata from ListenBrainz when moving downloaded tracks (slskd) (default: false)
# OVERWRITE_METADATA=false

# === Notifications ===

Expand Down
21 changes: 17 additions & 4 deletions src/client/emby.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ type EmbyPaths []struct {
RefreshStatus string `json:"RefreshStatus"`
}

type EmbyProviderIds struct {
MusicBrainzTrack string `json:"MusicBrainzTrack"`
}

type EmbyItemSearch struct {
Items []EmbyItems `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
Expand All @@ -30,6 +34,7 @@ type EmbyItems struct {
Name string `json:"Name"`
ServerID string `json:"ServerId"`
ID string `json:"Id"`
ProviderIds EmbyProviderIds `json:"ProviderIds"`
Path string `json:"Path"`
Album string `json:"Album,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`
Expand Down Expand Up @@ -138,16 +143,24 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error {
return err
}

normalizedTrackTitle := util.NormalizeTitle(track.Title)
normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle)
for _, item := range results.Items {
if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (util.NormalizeTitle(item.Name) == util.NormalizeTitle(track.Title) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) {

normalizedItemTitle := util.NormalizeTitle(item.Name)

musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID
titleMatch := normalizedItemTitle == normalizedTrackTitle || normalizedItemTitle == normalizedCleanTitle
artistMatch := strings.EqualFold(item.AlbumArtist, track.MainArtist) || (len(item.Artists) > 0 && strings.EqualFold(item.Artists[0], track.MainArtist))
pathMatch := util.ContainsFold(item.Path,track.File)

if musicBrainzMatch || (titleMatch && artistMatch) {
track.ID = item.ID
track.Present = true
break
}

if track.File != "" && len(item.Artists) > 0 &&
strings.Contains(strings.ToLower(item.Artists[0]), strings.ToLower(track.MainArtist)) &&
strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)) {
if track.File != "" && artistMatch && pathMatch {
track.ID = item.ID
track.Present = true
break
Expand Down
36 changes: 24 additions & 12 deletions src/client/jellyfin.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,19 @@ type Audios struct {
StartIndex int `json:"StartIndex"`
}

type ProviderIds struct {
MusicBrainzTrack string `json:"MusicBrainzTrack"`
}

type Items struct {
Name string `json:"Name"`
ServerID string `json:"ServerId"`
ID string `json:"Id"`
Path string `json:"Path"`
Album string `json:"Album,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`
Artists []string `json:"Artists"`
Name string `json:"Name"`
ServerID string `json:"ServerId"`
ID string `json:"Id"`
ProviderIds ProviderIds `json:"ProviderIds"`
Path string `json:"Path"`
Album string `json:"Album,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`
Artists []string `json:"Artists"`

}

Expand Down Expand Up @@ -152,17 +157,24 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error {
if err = util.ParseResp(body, &results); err != nil {
return err
}

normalizedTrackTitle := util.NormalizeTitle(track.Title)
normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle)
for _, item := range results.Items {
if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (util.NormalizeTitle(item.Name) == util.NormalizeTitle(track.Title) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) {

normalizedItemTitle := util.NormalizeTitle(item.Name)

musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID
titleMatch := normalizedItemTitle == normalizedTrackTitle || normalizedItemTitle == normalizedCleanTitle
artistMatch := strings.EqualFold(item.AlbumArtist, track.MainArtist) || (len(item.Artists) > 0 && strings.EqualFold(item.Artists[0], track.MainArtist))
pathMatch := util.ContainsFold(item.Path,track.File)

if musicBrainzMatch || (titleMatch && artistMatch) {
track.ID = item.ID
track.Present = true
break
}

if track.File != "" && len(item.Artists) > 0 &&
strings.Contains(strings.ToLower(item.Artists[0]), strings.ToLower(track.MainArtist)) &&
strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)) {
if track.File != "" && artistMatch && pathMatch {
track.ID = item.ID
track.Present = true
break
Expand Down
63 changes: 52 additions & 11 deletions src/client/plex.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ type PlexPlaylist struct {
} `json:"MediaContainer"`
}

type GUID struct {
ID string `json:"id"`
}
type Metadata struct {
GUID []GUID `json:"Guid"`
}

type Plex struct {
machineID string
LibraryID string
Expand Down Expand Up @@ -410,7 +417,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error {
}
}

key, err := getPlexSong(track, all)
key, err := c.getPlexSong(track, all)
if err != nil {
slog.Warn("failed to find match", "title", track.Title, "err", err)
continue
Expand Down Expand Up @@ -549,20 +556,32 @@ func (c *Plex) getServer() error {
return nil
}

func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) {
normArtist := util.AlnumOnly(strings.ToLower(track.MainArtist))
func (c *Plex) getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) {
normArtist := util.AlnumOnly(track.MainArtist)
normalizedTrackTitle := util.NormalizeTitle(track.Title)
normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle)
normalizedAlbum := util.AlnumOnly(strings.ToLower(track.Album))

for _, md := range metadata {
if md.Type != "track" {
continue
}

titleMatch := util.NormalizeTitle(md.Title) == util.NormalizeTitle(track.Title)
albumMatch := util.AlnumOnly(strings.ToLower(md.ParentTitle)) == util.AlnumOnly(strings.ToLower(track.Album))
artistMatch := strings.Contains(util.AlnumOnly(strings.ToLower(md.OriginalTitle)), normArtist) || strings.Contains(util.AlnumOnly(strings.ToLower(md.GrandparentTitle)), normArtist)

if titleMatch && (albumMatch || artistMatch) {
slog.Debug(fmt.Sprintf("matched track via metadata: %s by %s", track.Title, track.Artist))
var mbid string;
if c.AdminClient != nil {
mbid = c.AdminClient.getPlexMBID(md.RatingKey)
} else {
mbid = c.getPlexMBID(md.RatingKey)
}

normalizedSongTitle := util.NormalizeTitle(md.Title)
musicBrainzMatch := mbid != "" && track.MusicBrainzReleaseTrackID == mbid
titleMatch := normalizedSongTitle == normalizedTrackTitle || normalizedSongTitle == normalizedCleanTitle
albumMatch := util.AlnumOnly(strings.ToLower(md.ParentTitle)) == normalizedAlbum
artistMatch := util.ContainsFold(util.AlnumOnly(md.OriginalTitle), normArtist) || util.ContainsFold(util.AlnumOnly(md.GrandparentTitle), normArtist)

if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) {
slog.Debug("matched track via metadata", "title", track.Title, "artist", track.Artist)
return md.Key, nil
}

Expand All @@ -571,11 +590,11 @@ func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) {
}

media := md.Media[0]
pathMatch := strings.Contains(strings.ToLower(media.Part[0].File), strings.ToLower(track.File))
pathMatch := util.ContainsFold(media.Part[0].File, track.File)
durationMatch := util.Abs(media.Duration-track.Duration) < 10000 // duration within 10s

if durationMatch && pathMatch {
slog.Debug(fmt.Sprintf("matched track via path: %s by %s", track.Title, track.Artist))
slog.Debug("matched track via path", "title", track.Title, "artist", track.Artist)
return md.Key, nil
}
}
Expand All @@ -584,6 +603,28 @@ func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) {
return "", fmt.Errorf("failed to find '%s' by '%s' in '%s'", track.Title, track.Artist, track.Album)
}

func (c *Plex) getPlexMBID(ratingKey string) string {
params := fmt.Sprintf("/library/metadata/%s", ratingKey)


body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers)
if err != nil {
return ""
}

var metadata Metadata
if err = util.ParseResp(body, &metadata); err != nil {
return ""
}
prefix := "mbid://"
for _, guid := range metadata.GUID {
if strings.HasPrefix(guid.ID, prefix) {
return strings.TrimPrefix(guid.ID, prefix)
}
}
return ""
}

func (c *Plex) addtoPlaylist(tracks []*models.Track) {
for _, track := range tracks {
if track.ID != "" {
Expand Down
29 changes: 18 additions & 11 deletions src/client/subsonic.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,19 @@ type SubResponse struct {
ServerVersion string `json:"serverVersion"`
SearchResult3 struct {
Song []struct {
ID string `json:"id"`
Title string `json:"title"`
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Duration int `json:"duration"`
MusicBrainzID string `json:"musicBrainzId"`
Path string `json:"path"`
} `json:"song"`
} `json:"searchResult3,omitempty"`
} `json:"searchResult3"`
Playlists struct {
Playlist []Playlist `json:"playlist,omitempty"`
} `json:"playlists,omitempty"`
Playlist Playlist `json:"playlist,omitempty"`
Playlist []Playlist `json:"playlist"`
} `json:"playlists"`
Playlist Playlist `json:"playlist"`
} `json:"subsonic-response"`
}

Expand Down Expand Up @@ -143,14 +145,19 @@ func (c *Subsonic) SearchSongs(tracks []*models.Track) error {
slog.Debug(fmt.Sprintf("[subsonic] no results found for %s", searchQuery))
continue
}

normalizedTrackTitle := util.NormalizeTitle(track.Title)
normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle)
for _, song := range songs {
artistMatch := strings.Contains(strings.ToLower(song.Artist), strings.ToLower(track.MainArtist))
titleMatch := util.NormalizeTitle(song.Title) == util.NormalizeTitle(track.Title)
normalizedSongTitle := util.NormalizeTitle(song.Title)

musicBrainzMatch := track.MusicBrainzTrackID != "" && song.MusicBrainzID == track.MusicBrainzTrackID
artistMatch := util.ContainsFold(song.Artist, track.MainArtist)
albumMatch := util.ContainsFold(song.Album, track.Album)
titleMatch := normalizedSongTitle == normalizedTrackTitle || normalizedSongTitle == normalizedCleanTitle
durationMatch := util.Abs(song.Duration - (track.Duration / 1000)) < 10
pathMatch := strings.Contains(strings.ToLower(song.Path), strings.ToLower(track.File))
pathMatch := util.ContainsFold(song.Path, track.File)

if artistMatch && titleMatch {
if musicBrainzMatch || (titleMatch && (albumMatch || artistMatch)) {
track.ID = song.ID
track.Present = true
break
Expand Down
26 changes: 14 additions & 12 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,19 @@ type SubsonicConfig struct {
}

type DownloadConfig struct {
DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
PathTemplate string `env:"PATH_TEMPLATE"`
Youtube Youtube
YoutubeMusic YoutubeMusic
Slskd Slskd
ExcludeLocal bool
KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download
RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format
UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"`
Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"`
DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
PathTemplate string `env:"PATH_TEMPLATE"`
Youtube Youtube
YoutubeMusic YoutubeMusic
Slskd Slskd
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
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"`
Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"`
}

type Filters struct {
Expand Down Expand Up @@ -215,7 +217,7 @@ func (cfg *Config) ReadEnv() {

func (cfg *Config) CommonFixes() {
cfg.DownloadCfg.Youtube.FileExtension = strings.TrimPrefix(cfg.DownloadCfg.Youtube.FileExtension, ".")
cfg.DownloadCfg.Youtube.CoversDir = filepath.Join(filepath.Dir(cfg.Flags.CfgPath), "cache", "covers")
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.NormalizeDir()
Expand Down
8 changes: 4 additions & 4 deletions src/discovery/listenbrainz.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,14 +461,14 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) (
var mbData *MBRecording
var mbErr error
for attempt := 1; attempt <= 3; attempt++ {

if attempt <= 3 {
time.Sleep(time.Duration(waitTime) * time.Second)
}
mbData, mbErr = c.mbRequest(fmt.Sprintf("recording/%s?inc=media+releases+artist-credits+release-groups&fmt=json", track.MusicBrainzTrackID))
if mbErr == nil && mbData != nil {
break
}

if attempt < 3 {
time.Sleep(time.Duration(waitTime) * time.Second)
}
}

if mbData != nil {
Expand Down
Loading
Loading