diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 08d911d..af9547d 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -41,7 +41,8 @@ type Audios struct { } type ProviderIds struct { - MusicBrainzTrack string `json:"MusicBrainzTrack"` + MusicBrainzTrack string `json:"MusicBrainzTrack"` + MusicBrainzRecording string `json:"MusicBrainzRecording"` } type Items struct { @@ -150,8 +151,13 @@ func (c *Jellyfin) CheckRefreshState() bool { func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path,ProviderIDs", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle))) + // Clean typography drift from the search parameters + cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") + cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "`", "'") + cleanSearchTitle = strings.TrimSpace(cleanSearchTitle) + // 1. Fetch candidates using the standard search term + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(cleanSearchTitle)) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { return err @@ -161,23 +167,67 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { if err = util.ParseResp(body, &results); err != nil { return err } - normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) - for _, item := range results.Items { - - normalizedItemTitle := util.NormalizeTitle(item.Name) - musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID - titleMatch := 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) + // 2. Robust Root-Word Fallback: If primary search returns 0 results, + // isolate the very first continuous block of alphanumeric characters (stops at spaces, apostrophes, etc.) + if len(results.Items) == 0 && len(cleanSearchTitle) > 0 { + endIdx := 0 + for i, r := range cleanSearchTitle { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + endIdx = i + 1 + } else if endIdx > 0 { + break + } + } - if musicBrainzMatch || (titleMatch && artistMatch) { - track.ID = item.ID - track.Present = true - break + if endIdx > 0 { + firstWord := cleanSearchTitle[:endIdx] + broadParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(firstWord)) + broadBody, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+broadParam, nil, c.Cfg.Creds.Headers) + if err == nil { + _ = util.ParseResp(broadBody, &results) + } } + } - if track.File != "" && artistMatch && pathMatch { + targetAlnumTitle := util.NormalizeTitle(track.CleanTitle) + rawIncomingArtist := strings.ToLower(track.MainArtist) + normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) + + for _, item := range results.Items { + currentAlnumItemTitle := util.NormalizeTitle(item.Name) + + // 1. Strict MusicBrainz Recording/Track ID Match + musicBrainzMatch := track.MusicBrainzTrackID != "" && + (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || + item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) + + // 2. Pure Alphanumeric Title Match + titleMatch := currentAlnumItemTitle == targetAlnumTitle + + // 3. Substring-Aware Artist Matching + artistMatch := false + if normalizedMainArtist != "" { + normalizedAlbumArtist := util.NormalizeArtist(item.AlbumArtist) + + if normalizedAlbumArtist == normalizedMainArtist || + strings.Contains(normalizedMainArtist, normalizedAlbumArtist) || + strings.Contains(normalizedAlbumArtist, normalizedMainArtist) { + artistMatch = true + } else { + for _, individualArtist := range item.Artists { + normalizedIndiv := util.NormalizeArtist(individualArtist) + if normalizedIndiv == normalizedMainArtist || + strings.Contains(normalizedMainArtist, normalizedIndiv) || + util.ContainsFold(rawIncomingArtist, individualArtist) { + artistMatch = true + break + } + } + } + } + + if musicBrainzMatch || (titleMatch && artistMatch) { track.ID = item.ID track.Present = true break diff --git a/src/config/config.go b/src/config/config.go index 512c6a9..60843e1 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -96,6 +96,7 @@ type SubsonicConfig struct { type DownloadConfig struct { DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` PathTemplate string `env:"PATH_TEMPLATE"` + DownloadAttempts int `env:"DOWNLOAD_ATTEMPTS" env-default:"3"` Youtube Youtube YoutubeMusic YoutubeMusic Slskd Slskd diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 9451f66..7990b9f 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -33,6 +33,23 @@ type Downloader interface { Monitor } +// retry runs fn up to attempts times with linear backoff, returning nil on the +// first success. Used for transient network failures on query/download. +func retry(attempts int, baseDelay time.Duration, label string, fn func() error) error { + var err error + for i := 1; i <= attempts; i++ { + if err = fn(); err == nil { + return nil + } + if i < attempts { + slog.Warn("retrying after failure", + "step", label, "attempt", i, "max", attempts, "context", err.Error()) + time.Sleep(time.Duration(i) * baseDelay) + } + } + return err +} + // get download services from config and append them to DownloadClient func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterLocal bool) (*DownloadClient, error) { var downloader []Downloader @@ -86,20 +103,31 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) { return err } - if err := d.QueryTrack(track); err != nil { + attempts := c.Cfg.DownloadAttempts + if attempts < 1 { + attempts = 1 + } + + if err := retry(attempts, 3*time.Second, "query", func() error { + if werr := limiter.Wait(ctx); werr != nil { + return werr + } + return d.QueryTrack(track) + }); err != nil { slog.Warn(err.Error()) return nil } - - if err := limiter.Wait(ctx); err != nil { - return err - } - - if err := d.GetTrack(track); err != nil { + + if err := retry(attempts, 3*time.Second, "download", func() error { + if werr := limiter.Wait(ctx); werr != nil { + return werr + } + return d.GetTrack(track) + }); err != nil { slog.Warn(err.Error()) return nil } - + return nil }) } @@ -382,4 +410,4 @@ func isDirEmpty(path string) (bool, error) { return true, nil // no entries } return false, err -} \ No newline at end of file +} diff --git a/src/util/sanitize.go b/src/util/sanitize.go index 35c73ec..861fb71 100644 --- a/src/util/sanitize.go +++ b/src/util/sanitize.go @@ -8,26 +8,75 @@ import ( var ( filenameRe = regexp.MustCompile(`[^\p{L}\d._,\-]+`) alnumRe = regexp.MustCompile(`[^\p{L}\d]+`) + + // Captures the parts before and after a featuring clause + featSplitRe = regexp.MustCompile(`(?i)\s+(?:feat\.?|featuring|ft\.?|with)\s+`) + + // Existing regexes kept for compatibility featTailRe = regexp.MustCompile(`(?i)\s*[\(\[\{]\s*(feat\.?|featuring|ft\.?|with)\s[^\)\]\}]*[\)\]\}]\s*$`) + featBareRe = regexp.MustCompile(`(?i)\s+(feat\.?|featuring|ft\.?)\s+.*$`) remasterTailRe = regexp.MustCompile(`(?i)\s*[-–—]\s*\d{4}\s*remaster(ed)?\s*$`) + + // Clean up internal brackets inside featuring clauses if parenthesized: "(feat. Rahzel)" -> "Rahzel" + bracketTrimRe = regexp.MustCompile(`[()\[\]{}]`) ) -// CleanSearchTitle strips trailing (feat. …) and "- 2011 Remaster" suffixes -// but keeps the title human-readable for use in search API queries. -func CleanSearchTitle(s string) string { +// ConvertFeatToSemicolon rewrites "Artist A feat. Artist B" to "Artist A; Artist B" +// This aligns string formatting directly with Jellyfin's multi-artist tracking schema. +func ConvertFeatToSemicolon(s string) string { + if !featSplitRe.MatchString(s) && !featTailRe.MatchString(s) { + return strings.TrimSpace(s) + } + + // Handle parenthesized features like: Bring Me the Horizon (feat. Rahzel) + if featTailRe.MatchString(s) { + cleaned := bracketTrimRe.ReplaceAllString(s, "") + parts := featSplitRe.Split(cleaned, 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[0]) + "; " + strings.TrimSpace(parts[1]) + } + } + + // Handle bare features like: Bring Me the Horizon feat. Rahzel + parts := featSplitRe.Split(s, 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[0]) + "; " + strings.TrimSpace(parts[1]) + } + + return strings.TrimSpace(s) +} + +// StripFeat removes a trailing "feat./ft./featuring …" clause, whether +// parenthesized "(feat. X)" or bare " feat. X". Works for titles and artists. +func StripFeat(s string) string { s = featTailRe.ReplaceAllString(s, "") - s = remasterTailRe.ReplaceAllString(s, "") + s = featBareRe.ReplaceAllString(s, "") return strings.TrimSpace(s) } -// NormalizeTitle strips trailing (feat. …) annotations, lowercases, -// and reduces to alphanumeric-only for fuzzy title comparison. +// NormalizeTitle strips feat annotations and "- YYYY Remaster" suffixes, +// lowercases, and reduces to alphanumeric-only for fuzzy comparison. func NormalizeTitle(s string) string { - s = featTailRe.ReplaceAllString(s, "") + s = StripFeat(s) s = remasterTailRe.ReplaceAllString(s, "") return AlnumOnly(strings.ToLower(s)) } +// NormalizeArtist normalizes featuring clauses to semicolons instead of dropping them, +// ensuring accurate database alignment for tracks with multi-artist records. +func NormalizeArtist(s string) string { + s = ConvertFeatToSemicolon(s) + return strings.ToLower(s) +} + +// CleanSearchTitle strips trailing (feat. …) and "- 2011 Remaster" suffixes +// but keeps the title human-readable for use in search API queries. +func CleanSearchTitle(s string) string { + s = featTailRe.ReplaceAllString(s, "") + s = remasterTailRe.ReplaceAllString(s, "") + return strings.TrimSpace(s) +} + // FilenameSafe replaces characters unsafe for filenames with '_' func FilenameSafe(s string) string { return filenameRe.ReplaceAllString(s, "_") @@ -41,4 +90,4 @@ func AlnumOnly(s string) string { // Case insensitive check if substring is present in s func ContainsFold(s, substr string) bool { return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) -} \ No newline at end of file +}