Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3fa7ba4
Update downloader.go
gtkashi Jul 1, 2026
2800b65
Add DownloadAttempts field to DownloadConfig
gtkashi Jul 1, 2026
4076d2f
Refactor title cleaning functions for clarity
gtkashi Jul 1, 2026
9a06abb
Add MusicBrainzRecording to ProviderIds and update matching logic
gtkashi Jul 1, 2026
b15e075
Add GitHub Actions workflow for Docker image build
gtkashi Jul 1, 2026
3122c75
Add GitHub Actions workflow for Docker image build
gtkashi Jul 1, 2026
28db79c
Enhance feature handling in artist normalization
gtkashi Jul 1, 2026
f4b0ea6
Enhance artist matching logic in jellyfin.go
gtkashi Jul 2, 2026
1ad0260
Enhance SearchSongs method for better search accuracy
gtkashi Jul 2, 2026
8637cee
Refactor SearchSongs for improved title and artist matching
gtkashi Jul 2, 2026
2902fbc
Enhance song search functionality in Jellyfin
gtkashi Jul 2, 2026
d13209a
Enhance song search functionality in Jellyfin
gtkashi Jul 2, 2026
d249485
Add regexp import to jellyfin.go
gtkashi Jul 2, 2026
884e29e
Improve comments in SearchSongs method
gtkashi Jul 2, 2026
fdbb276
Refactor comments and add diagnostic logging in SearchSongs
gtkashi Jul 2, 2026
5b4e924
Refactor Jellyfin search query and logging
gtkashi Jul 2, 2026
85f4f0b
Refactor SearchSongs to improve search query handling
gtkashi Jul 2, 2026
3176d5d
Remove unused regexp import from jellyfin.go
gtkashi Jul 2, 2026
434945c
Refactor comments for clarity in SearchSongs function
gtkashi Jul 2, 2026
316e406
Refactor SearchSongs for improved search logic
gtkashi Jul 2, 2026
70570ca
Implement precise word-isolation fallback for search
gtkashi Jul 2, 2026
32bd951
Refactor search fallback logic in jellyfin.go
gtkashi Jul 2, 2026
9b6996e
Enhance fallback search logic for song titles
gtkashi Jul 2, 2026
86593b1
Delete docker.yml
gtkashi Jul 2, 2026
cb6a5d1
Delete .github/workflows/docker.yml
gtkashi Jul 2, 2026
152704d
Merge branch 'LumePart:dev' into dev
gtkashi Jul 6, 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
80 changes: 65 additions & 15 deletions src/client/jellyfin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 37 additions & 9 deletions src/downloader/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
})
}
Expand Down Expand Up @@ -382,4 +410,4 @@ func isDirEmpty(path string) (bool, error) {
return true, nil // no entries
}
return false, err
}
}
65 changes: 57 additions & 8 deletions src/util/sanitize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "_")
Expand All @@ -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))
}
}