From 3fa7ba469353a5204b89071faaaf19732add8ce9 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:37:25 -0700 Subject: [PATCH 01/25] Update downloader.go Adding retry logic for timeouts --- src/downloader/downloader.go | 46 +++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) 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 +} From 2800b651159d9f6e1e165a942e24c69be513975c Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:38:49 -0700 Subject: [PATCH 02/25] Add DownloadAttempts field to DownloadConfig --- src/config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/config.go b/src/config/config.go index 8eabc58..109a682 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 From 4076d2f051bf33b5d666c5c90a076ef2f3c49276 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:41:51 -0700 Subject: [PATCH 03/25] Refactor title cleaning functions for clarity Refactor title cleaning functions to improve clarity and maintainability. Introduced StripFeat for removing feat annotations and updated NormalizeTitle and NormalizeArtist to use it. --- src/util/sanitize.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/util/sanitize.go b/src/util/sanitize.go index 35c73ec..2f40438 100644 --- a/src/util/sanitize.go +++ b/src/util/sanitize.go @@ -9,25 +9,40 @@ var ( filenameRe = regexp.MustCompile(`[^\p{L}\d._,\-]+`) alnumRe = regexp.MustCompile(`[^\p{L}\d]+`) 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*$`) ) -// 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 { +// 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 strips a trailing feat clause and reduces to +// alphanumeric-only, so "Rihanna feat. Eminem" and "Rihanna" compare equal. +func NormalizeArtist(s string) string { + return AlnumOnly(strings.ToLower(StripFeat(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 +56,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 +} From 9a06abba98081a666bdecd645a8c9ae453cd4066 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:45:06 -0700 Subject: [PATCH 04/25] Add MusicBrainzRecording to ProviderIds and update matching logic --- src/client/jellyfin.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 08d911d..4edccd5 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 { @@ -162,21 +163,31 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { return err } normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) + normalizedMainArtist := util.NormalizeArtist(track.MainArtist) + for _, item := range results.Items { - normalizedItemTitle := util.NormalizeTitle(item.Name) - - musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID + + // track.MusicBrainzTrackID holds a recording MBID; Jellyfin exposes that + // as MusicBrainzRecording. Check MusicBrainzTrack too as a fallback. + musicBrainzMatch := track.MusicBrainzTrackID != "" && + (item.ProviderIds.MusicBrainzRecording == 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) - + + artistMatch := normalizedMainArtist != "" && + (util.NormalizeArtist(item.AlbumArtist) == normalizedMainArtist || + (len(item.Artists) > 0 && util.NormalizeArtist(item.Artists[0]) == normalizedMainArtist)) + + pathMatch := util.ContainsFold(item.Path, track.File) + if musicBrainzMatch || (titleMatch && artistMatch) { track.ID = item.ID track.Present = true break } - + if track.File != "" && artistMatch && pathMatch { track.ID = item.ID track.Present = true From b15e0751fa4c73f3e5c051a80500ea65bdc15afe Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:54:35 -0700 Subject: [PATCH 05/25] Add GitHub Actions workflow for Docker image build --- docker.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docker.yml diff --git a/docker.yml b/docker.yml new file mode 100644 index 0000000..43a5b6f --- /dev/null +++ b/docker.yml @@ -0,0 +1,34 @@ +name: Build and push image + +on: + push: + branches: [dev, main, master] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/gtkashi/explogtk:latest + build-args: VERSION=fork From 3122c75ceb34bf222ee109e7c05aba26e6bf662b Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:02:23 -0700 Subject: [PATCH 06/25] Add GitHub Actions workflow for Docker image build --- .github/workflows/docker.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..43a5b6f --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,34 @@ +name: Build and push image + +on: + push: + branches: [dev, main, master] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/gtkashi/explogtk:latest + build-args: VERSION=fork From 28db79caa0a329637d510134fa076d0b6d87b868 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:52:20 -0700 Subject: [PATCH 07/25] Enhance feature handling in artist normalization Updated functions to handle featuring clauses more accurately and normalize artist names with semicolons. --- src/util/sanitize.go | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/util/sanitize.go b/src/util/sanitize.go index 2f40438..861fb71 100644 --- a/src/util/sanitize.go +++ b/src/util/sanitize.go @@ -8,11 +8,44 @@ 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(`[()\[\]{}]`) ) +// 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 { @@ -29,10 +62,11 @@ func NormalizeTitle(s string) string { return AlnumOnly(strings.ToLower(s)) } -// NormalizeArtist strips a trailing feat clause and reduces to -// alphanumeric-only, so "Rihanna feat. Eminem" and "Rihanna" compare equal. +// 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 { - return AlnumOnly(strings.ToLower(StripFeat(s))) + s = ConvertFeatToSemicolon(s) + return strings.ToLower(s) } // CleanSearchTitle strips trailing (feat. …) and "- 2011 Remaster" suffixes From f4b0ea6da403949f7ce21fd24d7bf5a2a95c560c Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:13:57 -0700 Subject: [PATCH 08/25] Enhance artist matching logic in jellyfin.go Refactor artist matching logic to improve accuracy by isolating the primary artist and updating fuzzy matching. --- src/client/jellyfin.go | 46 ++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 4edccd5..9e09432 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -163,30 +163,42 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { return err } normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) - normalizedMainArtist := util.NormalizeArtist(track.MainArtist) + // 1. Keep things clean by using your StripFeat logic to isolate the true primary artist + normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) for _, item := range results.Items { - normalizedItemTitle := util.NormalizeTitle(item.Name) + normalizedItemTitle := util.NormalizeTitle(item.Name) - // track.MusicBrainzTrackID holds a recording MBID; Jellyfin exposes that - // as MusicBrainzRecording. Check MusicBrainzTrack too as a fallback. - musicBrainzMatch := track.MusicBrainzTrackID != "" && - (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || - item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) + musicBrainzMatch := track.MusicBrainzTrackID != "" && + (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || + item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - titleMatch := normalizedItemTitle == normalizedCleanTitle + titleMatch := normalizedItemTitle == normalizedCleanTitle - artistMatch := normalizedMainArtist != "" && - (util.NormalizeArtist(item.AlbumArtist) == normalizedMainArtist || - (len(item.Artists) > 0 && util.NormalizeArtist(item.Artists[0]) == normalizedMainArtist)) + // 2. Updated Fuzzy Artist Matching Logic + artistMatch := false + if normalizedMainArtist != "" { + // Check if MainArtist matches AlbumArtist cleanly + if util.NormalizeArtist(item.AlbumArtist) == normalizedMainArtist { + artistMatch = true + } else { + // Loop through all artists returned by Jellyfin to find a match for the main artist + for _, individualArtist := range item.Artists { + if util.NormalizeArtist(individualArtist) == normalizedMainArtist { + artistMatch = true + break + } + } + } + } - pathMatch := util.ContainsFold(item.Path, track.File) + pathMatch := util.ContainsFold(item.Path, track.File) - if musicBrainzMatch || (titleMatch && artistMatch) { - track.ID = item.ID - track.Present = true - break - } + if musicBrainzMatch || (titleMatch && artistMatch) { + track.ID = item.ID + track.Present = true + break + } if track.File != "" && artistMatch && pathMatch { track.ID = item.ID From 1ad0260d54664babbab6a9942a512ce6dc2a283f Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:02:15 -0700 Subject: [PATCH 09/25] Enhance SearchSongs method for better search accuracy Refactor SearchSongs to improve search parameters by stripping punctuation, isolating primary artist, and using explicit filters. --- src/client/jellyfin.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 9e09432..5f9a988 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -151,7 +151,25 @@ 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))) + // Fix 1: Strip punctuation like curly apostrophes from the Title search parameter + cleanTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") + cleanTitle = util.CleanSearchTitle(cleanTitle) + + // Fix 2: Isolate the absolute primary artist keyword (e.g., "Linkin Park" instead of the whole reinterpreted string) + // We split on common breaking words to ensure Jellyfin's search engine isn't confused + primaryArtist := track.MainArtist + for _, delimiter := range []string{" feat", " ft", " featuring", " &", " reinterpreted", " with", " x ", " and "} { + if idx := strings.Index(strings.ToLower(primaryArtist), delimiter); idx != -1 { + primaryArtist = primaryArtist[:idx] + break + } + } + + // Fix 3: Use Jellyfin's explicit Artist and Name filters instead of a loose global SearchTerm + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&ArtistType=Artist&Artists=%s&Name=%s&Recursive=true&Fields=Path,ProviderIDs", + url.QueryEscape(strings.TrimSpace(primaryArtist)), + url.QueryEscape(strings.TrimSpace(cleanTitle)), + ) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { From 8637cee3ed0a96a6e5de7acd28476d56a6298cf0 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:13:28 -0700 Subject: [PATCH 10/25] Refactor SearchSongs for improved title and artist matching --- src/client/jellyfin.go | 94 +++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 5f9a988..2ed8bd0 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -151,25 +151,12 @@ func (c *Jellyfin) CheckRefreshState() bool { func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - // Fix 1: Strip punctuation like curly apostrophes from the Title search parameter - cleanTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") - cleanTitle = util.CleanSearchTitle(cleanTitle) + // Clean punctuation from the title to make the SearchTerm more reliable + cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") + cleanSearchTitle = util.CleanSearchTitle(cleanSearchTitle) - // Fix 2: Isolate the absolute primary artist keyword (e.g., "Linkin Park" instead of the whole reinterpreted string) - // We split on common breaking words to ensure Jellyfin's search engine isn't confused - primaryArtist := track.MainArtist - for _, delimiter := range []string{" feat", " ft", " featuring", " &", " reinterpreted", " with", " x ", " and "} { - if idx := strings.Index(strings.ToLower(primaryArtist), delimiter); idx != -1 { - primaryArtist = primaryArtist[:idx] - break - } - } - - // Fix 3: Use Jellyfin's explicit Artist and Name filters instead of a loose global SearchTerm - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&ArtistType=Artist&Artists=%s&Name=%s&Recursive=true&Fields=Path,ProviderIDs", - url.QueryEscape(strings.TrimSpace(primaryArtist)), - url.QueryEscape(strings.TrimSpace(cleanTitle)), - ) + // Use the fast SearchTerm endpoint to avoid Jellyfin database timeouts + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path,ProviderIDs", url.QueryEscape(cleanSearchTitle)) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -180,43 +167,56 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { if err = util.ParseResp(body, &results); err != nil { return err } + + // Normalize our target fields for comparison normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) - // 1. Keep things clean by using your StripFeat logic to isolate the true primary artist - normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) + + // Extract the absolute base primary artist name (e.g., "Linkin Park" or "Nomyn") + primaryArtist := track.MainArtist + for _, delimiter := range []string{" feat", " ft", " featuring", " &", " reinterpreted", " with", " x ", " and "} { + if idx := strings.Index(strings.ToLower(primaryArtist), delimiter); idx != -1 { + primaryArtist = primaryArtist[:idx] + break + } + } + normalizedMainArtist := util.NormalizeArtist(strings.TrimSpace(primaryArtist)) for _, item := range results.Items { - normalizedItemTitle := util.NormalizeTitle(item.Name) + // Normalize punctuation and layout of the current item title from Jellyfin + normalizedItemTitle := util.NormalizeTitle(strings.ReplaceAll(item.Name, "’", "'")) - musicBrainzMatch := track.MusicBrainzTrackID != "" && - (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || - item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) + // 1. Strict MusicBrainz Recording/Track ID Match + musicBrainzMatch := track.MusicBrainzTrackID != "" && + (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || + item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - titleMatch := normalizedItemTitle == normalizedCleanTitle + // 2. Title Match (Fuzzy punctuation matched) + titleMatch := normalizedItemTitle == normalizedCleanTitle - // 2. Updated Fuzzy Artist Matching Logic - artistMatch := false - if normalizedMainArtist != "" { - // Check if MainArtist matches AlbumArtist cleanly - if util.NormalizeArtist(item.AlbumArtist) == normalizedMainArtist { - artistMatch = true - } else { - // Loop through all artists returned by Jellyfin to find a match for the main artist - for _, individualArtist := range item.Artists { - if util.NormalizeArtist(individualArtist) == normalizedMainArtist { - artistMatch = true - break - } - } - } - } + // 3. Robust Multi-Artist / Array Match + artistMatch := false + if normalizedMainArtist != "" { + // Check if the primary artist matches Jellyfin's AlbumArtist field + if util.NormalizeArtist(item.AlbumArtist) == normalizedMainArtist { + artistMatch = true + } else { + // Check Jellyfin's split string array for individual artists + for _, individualArtist := range item.Artists { + if util.NormalizeArtist(individualArtist) == normalizedMainArtist { + artistMatch = true + break + } + } + } + } - pathMatch := util.ContainsFold(item.Path, track.File) + pathMatch := util.ContainsFold(item.Path, track.File) - if musicBrainzMatch || (titleMatch && artistMatch) { - track.ID = item.ID - track.Present = true - break - } + if musicBrainzMatch || (titleMatch && artistMatch) { + track.ID = item.ID + track.Present = true + break + } if track.File != "" && artistMatch && pathMatch { track.ID = item.ID From 2902fbc40777ada490811ad738344f8d4256a024 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:23:11 -0700 Subject: [PATCH 11/25] Enhance song search functionality in Jellyfin Updated search logic to improve reliability and added limit to prevent truncation of results. --- src/client/jellyfin.go | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 2ed8bd0..ebc358c 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -151,12 +151,12 @@ func (c *Jellyfin) CheckRefreshState() bool { func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - // Clean punctuation from the title to make the SearchTerm more reliable + // Clean typography drift from the search parameters cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") cleanSearchTitle = util.CleanSearchTitle(cleanSearchTitle) - // Use the fast SearchTerm endpoint to avoid Jellyfin database timeouts - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path,ProviderIDs", url.QueryEscape(cleanSearchTitle)) + // Added Limit=300 to ensure generic terms like "heavy metal" aren't cut off by Jellyfin's default limits + 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 { @@ -168,21 +168,15 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { return err } - // Normalize our target fields for comparison normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) - // Extract the absolute base primary artist name (e.g., "Linkin Park" or "Nomyn") - primaryArtist := track.MainArtist - for _, delimiter := range []string{" feat", " ft", " featuring", " &", " reinterpreted", " with", " x ", " and "} { - if idx := strings.Index(strings.ToLower(primaryArtist), delimiter); idx != -1 { - primaryArtist = primaryArtist[:idx] - break - } - } - normalizedMainArtist := util.NormalizeArtist(strings.TrimSpace(primaryArtist)) + // Keep a lowercase version of the raw incoming artist string for substring fallback checks + rawIncomingArtist := strings.ToLower(track.MainArtist) + + // Clean the primary artist down to an alphanumeric string + normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) for _, item := range results.Items { - // Normalize punctuation and layout of the current item title from Jellyfin normalizedItemTitle := util.NormalizeTitle(strings.ReplaceAll(item.Name, "’", "'")) // 1. Strict MusicBrainz Recording/Track ID Match @@ -190,19 +184,26 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - // 2. Title Match (Fuzzy punctuation matched) + // 2. Title Match titleMatch := normalizedItemTitle == normalizedCleanTitle - // 3. Robust Multi-Artist / Array Match + // 3. Substring-Aware Artist Matching artistMatch := false if normalizedMainArtist != "" { - // Check if the primary artist matches Jellyfin's AlbumArtist field - if util.NormalizeArtist(item.AlbumArtist) == normalizedMainArtist { + normalizedAlbumArtist := util.NormalizeArtist(item.AlbumArtist) + + // Direct match or check if one is a substring of the other (handles "A & B" vs "A") + if normalizedAlbumArtist == normalizedMainArtist || + strings.Contains(normalizedMainArtist, normalizedAlbumArtist) || + strings.Contains(normalizedAlbumArtist, normalizedMainArtist) { artistMatch = true } else { - // Check Jellyfin's split string array for individual artists + // Check individual entries in Jellyfin's artist array for _, individualArtist := range item.Artists { - if util.NormalizeArtist(individualArtist) == normalizedMainArtist { + normalizedIndiv := util.NormalizeArtist(individualArtist) + if normalizedIndiv == normalizedMainArtist || + strings.Contains(normalizedMainArtist, normalizedIndiv) || + util.ContainsFold(rawIncomingArtist, individualArtist) { artistMatch = true break } From d13209ad4961915e91ec8804b1619eb4abb1b570 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:36:18 -0700 Subject: [PATCH 12/25] Enhance song search functionality in Jellyfin Added regex to clean search titles and improved normalization of item titles. --- src/client/jellyfin.go | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index ebc358c..12f833f 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -150,13 +150,19 @@ func (c *Jellyfin) CheckRefreshState() bool { } func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { + // Match anything inside parentheses or brackets, along with the brackets themselves + parenthesesRe := regexp.MustCompile(`[\(\[\{].*?[\)\]\}]`) + for _, track := range tracks { - // Clean typography drift from the search parameters + // 1. Sanitize the raw SearchTerm completely to prevent Jellyfin syntax errors cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") + cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "`", "'") + cleanSearchTitle = parenthesesRe.ReplaceAllString(cleanSearchTitle, "") // Strip (Air Raid Vehicle), etc. cleanSearchTitle = util.CleanSearchTitle(cleanSearchTitle) + cleanSearchTitle = strings.ToLower(cleanSearchTitle) // Force lowercase for reliable matching - // Added Limit=300 to ensure generic terms like "heavy metal" aren't cut off by Jellyfin's default limits - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(cleanSearchTitle)) + // Query using our stripped down, safe search string + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -169,36 +175,33 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { } normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) - - // Keep a lowercase version of the raw incoming artist string for substring fallback checks rawIncomingArtist := strings.ToLower(track.MainArtist) - - // Clean the primary artist down to an alphanumeric string normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) for _, item := range results.Items { - normalizedItemTitle := util.NormalizeTitle(strings.ReplaceAll(item.Name, "’", "'")) + // Normalize punctuation and layout of the current item title from Jellyfin + itemTitleCleaned := strings.ReplaceAll(item.Name, "’", "'") + itemTitleCleaned = strings.ReplaceAll(itemTitleCleaned, "`", "'") + normalizedItemTitle := util.NormalizeTitle(itemTitleCleaned) - // 1. Strict MusicBrainz Recording/Track ID Match + // Strict MusicBrainz Recording/Track ID Match musicBrainzMatch := track.MusicBrainzTrackID != "" && (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - // 2. Title Match + // Title Match titleMatch := normalizedItemTitle == normalizedCleanTitle - // 3. Substring-Aware Artist Matching + // Substring-Aware Artist Matching artistMatch := false if normalizedMainArtist != "" { normalizedAlbumArtist := util.NormalizeArtist(item.AlbumArtist) - // Direct match or check if one is a substring of the other (handles "A & B" vs "A") if normalizedAlbumArtist == normalizedMainArtist || strings.Contains(normalizedMainArtist, normalizedAlbumArtist) || strings.Contains(normalizedAlbumArtist, normalizedMainArtist) { artistMatch = true } else { - // Check individual entries in Jellyfin's artist array for _, individualArtist := range item.Artists { normalizedIndiv := util.NormalizeArtist(individualArtist) if normalizedIndiv == normalizedMainArtist || From d2494850e65f52b6d8ec7a0b384bf4836651060d Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:47:47 -0700 Subject: [PATCH 13/25] Add regexp import to jellyfin.go --- src/client/jellyfin.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 12f833f..dbbcd9d 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "regexp" // ➕ Add this line right here "strings" "log/slog" From 884e29e81af3fa3bb88af5069676981de7f59d8c Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:06:03 -0700 Subject: [PATCH 14/25] Improve comments in SearchSongs method Refactor comments for clarity and structure in SearchSongs method. --- src/client/jellyfin.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index dbbcd9d..c8c85b4 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -151,16 +151,15 @@ func (c *Jellyfin) CheckRefreshState() bool { } func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { - // Match anything inside parentheses or brackets, along with the brackets themselves parenthesesRe := regexp.MustCompile(`[\(\[\{].*?[\)\]\}]`) for _, track := range tracks { - // 1. Sanitize the raw SearchTerm completely to prevent Jellyfin syntax errors + // Sanitize the raw SearchTerm completely to prevent Jellyfin syntax errors cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "`", "'") - cleanSearchTitle = parenthesesRe.ReplaceAllString(cleanSearchTitle, "") // Strip (Air Raid Vehicle), etc. + cleanSearchTitle = parenthesesRe.ReplaceAllString(cleanSearchTitle, "") cleanSearchTitle = util.CleanSearchTitle(cleanSearchTitle) - cleanSearchTitle = strings.ToLower(cleanSearchTitle) // Force lowercase for reliable matching + cleanSearchTitle = strings.ToLower(cleanSearchTitle) // Query using our stripped down, safe search string reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) @@ -180,20 +179,19 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) for _, item := range results.Items { - // Normalize punctuation and layout of the current item title from Jellyfin itemTitleCleaned := strings.ReplaceAll(item.Name, "’", "'") itemTitleCleaned = strings.ReplaceAll(itemTitleCleaned, "`", "'") normalizedItemTitle := util.NormalizeTitle(itemTitleCleaned) - // Strict MusicBrainz Recording/Track ID Match + // 1. Strict MusicBrainz Recording/Track ID Match musicBrainzMatch := track.MusicBrainzTrackID != "" && (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - // Title Match + // 2. Title Match titleMatch := normalizedItemTitle == normalizedCleanTitle - // Substring-Aware Artist Matching + // 3. Substring-Aware Artist Matching artistMatch := false if normalizedMainArtist != "" { normalizedAlbumArtist := util.NormalizeArtist(item.AlbumArtist) @@ -215,15 +213,17 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { } } - pathMatch := util.ContainsFold(item.Path, track.File) - + // NEW FIXED MATCH ACTION: If Title and Artist match perfectly, accept it! + // This completely bypasses any missing album subtitles or path checking constraints. if musicBrainzMatch || (titleMatch && artistMatch) { track.ID = item.ID track.Present = true break } - if track.File != "" && artistMatch && pathMatch { + // Broad fallback matching using path strings if track.File happens to be populated + pathMatch := track.File != "" && util.ContainsFold(item.Path, track.File) + if artistMatch && pathMatch { track.ID = item.ID track.Present = true break From fdbb2764a30b71fe2a7fc1004b904e5b90505ed7 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:12:06 -0700 Subject: [PATCH 15/25] Refactor comments and add diagnostic logging in SearchSongs Updated comments for clarity and added diagnostic logging for search results and item comparisons. --- src/client/jellyfin.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index c8c85b4..b8367f9 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -154,7 +154,7 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { parenthesesRe := regexp.MustCompile(`[\(\[\{].*?[\)\]\}]`) for _, track := range tracks { - // Sanitize the raw SearchTerm completely to prevent Jellyfin syntax errors + // Clean typography drift from the search parameters cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "`", "'") cleanSearchTitle = parenthesesRe.ReplaceAllString(cleanSearchTitle, "") @@ -174,6 +174,9 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { return err } + // DIAGNOSTIC LOG: See exactly what Jellyfin returned for this specific search term + slog.Debug(fmt.Sprintf("[EXPLO-DIAG] Search for '%s' returned %d results from Jellyfin", cleanSearchTitle, results.TotalRecordCount)) + normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) rawIncomingArtist := strings.ToLower(track.MainArtist) normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) @@ -183,15 +186,16 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { itemTitleCleaned = strings.ReplaceAll(itemTitleCleaned, "`", "'") normalizedItemTitle := util.NormalizeTitle(itemTitleCleaned) - // 1. Strict MusicBrainz Recording/Track ID Match + // DIAGNOSTIC LOG FOR EACH ITEM: See what text strings are actually being compared + slog.Debug(fmt.Sprintf("[EXPLO-DIAG] Comparing Track: '%s' vs Item: '%s' | Artist: '%s' vs AlbumArtist: '%s'", + normalizedCleanTitle, normalizedItemTitle, normalizedMainArtist, util.NormalizeArtist(item.AlbumArtist))) + musicBrainzMatch := track.MusicBrainzTrackID != "" && (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - // 2. Title Match titleMatch := normalizedItemTitle == normalizedCleanTitle - // 3. Substring-Aware Artist Matching artistMatch := false if normalizedMainArtist != "" { normalizedAlbumArtist := util.NormalizeArtist(item.AlbumArtist) @@ -213,15 +217,12 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { } } - // NEW FIXED MATCH ACTION: If Title and Artist match perfectly, accept it! - // This completely bypasses any missing album subtitles or path checking constraints. if musicBrainzMatch || (titleMatch && artistMatch) { track.ID = item.ID track.Present = true break } - // Broad fallback matching using path strings if track.File happens to be populated pathMatch := track.File != "" && util.ContainsFold(item.Path, track.File) if artistMatch && pathMatch { track.ID = item.ID From 5b4e924810cdb238457cb512e43539b7a075dd53 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:51:09 -0700 Subject: [PATCH 16/25] Refactor Jellyfin search query and logging Updated search query to use explicit Name lookup to avoid indexing bugs in Jellyfin. Removed diagnostic logging for search results and item comparisons. --- src/client/jellyfin.go | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index b8367f9..564e4b0 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -159,10 +159,10 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "`", "'") cleanSearchTitle = parenthesesRe.ReplaceAllString(cleanSearchTitle, "") cleanSearchTitle = util.CleanSearchTitle(cleanSearchTitle) - cleanSearchTitle = strings.ToLower(cleanSearchTitle) - // Query using our stripped down, safe search string - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) + // FIX: Use explicit Name lookup instead of volatile SearchTerm. + // This bypasses the search indexing bugs that cause Jellyfin to return 0 results. + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&Name=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -174,9 +174,6 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { return err } - // DIAGNOSTIC LOG: See exactly what Jellyfin returned for this specific search term - slog.Debug(fmt.Sprintf("[EXPLO-DIAG] Search for '%s' returned %d results from Jellyfin", cleanSearchTitle, results.TotalRecordCount)) - normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) rawIncomingArtist := strings.ToLower(track.MainArtist) normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) @@ -186,16 +183,15 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { itemTitleCleaned = strings.ReplaceAll(itemTitleCleaned, "`", "'") normalizedItemTitle := util.NormalizeTitle(itemTitleCleaned) - // DIAGNOSTIC LOG FOR EACH ITEM: See what text strings are actually being compared - slog.Debug(fmt.Sprintf("[EXPLO-DIAG] Comparing Track: '%s' vs Item: '%s' | Artist: '%s' vs AlbumArtist: '%s'", - normalizedCleanTitle, normalizedItemTitle, normalizedMainArtist, util.NormalizeArtist(item.AlbumArtist))) - + // 1. Strict MusicBrainz Recording/Track ID Match musicBrainzMatch := track.MusicBrainzTrackID != "" && (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) + // 2. Title Match titleMatch := normalizedItemTitle == normalizedCleanTitle + // 3. Substring-Aware Artist Matching artistMatch := false if normalizedMainArtist != "" { normalizedAlbumArtist := util.NormalizeArtist(item.AlbumArtist) @@ -222,13 +218,6 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { track.Present = true break } - - pathMatch := track.File != "" && util.ContainsFold(item.Path, track.File) - if artistMatch && pathMatch { - track.ID = item.ID - track.Present = true - break - } } if !track.Present { From 85f4f0bd6f91dcfa44c8dfabb85ec3e4480c9880 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:04:44 -0700 Subject: [PATCH 17/25] Refactor SearchSongs to improve search query handling Updated search functionality to clean typography drift and revert to indexed SearchTerm query for better results. --- src/client/jellyfin.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 564e4b0..a241f5f 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -151,18 +151,16 @@ func (c *Jellyfin) CheckRefreshState() bool { } func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { - parenthesesRe := regexp.MustCompile(`[\(\[\{].*?[\)\]\}]`) - for _, track := range tracks { - // Clean typography drift from the search parameters + // Clean ONLY the problematic typography drift before hitting the API. + // Leave the actual words, brackets, and spaces alone so Jellyfin's index matches. cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "`", "'") - cleanSearchTitle = parenthesesRe.ReplaceAllString(cleanSearchTitle, "") - cleanSearchTitle = util.CleanSearchTitle(cleanSearchTitle) + cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "“", "\"") + cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "”", "\"") - // FIX: Use explicit Name lookup instead of volatile SearchTerm. - // This bypasses the search indexing bugs that cause Jellyfin to return 0 results. - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&Name=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) + // Revert to the indexed SearchTerm query that successfully found your tracks + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -179,6 +177,7 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) for _, item := range results.Items { + // Normalize punctuation for the database items we are evaluating itemTitleCleaned := strings.ReplaceAll(item.Name, "’", "'") itemTitleCleaned = strings.ReplaceAll(itemTitleCleaned, "`", "'") normalizedItemTitle := util.NormalizeTitle(itemTitleCleaned) From 3176d5dc292456ae3a3e671b35fc293e8201b414 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:08:57 -0700 Subject: [PATCH 18/25] Remove unused regexp import from jellyfin.go Removed unused regexp import from jellyfin.go --- src/client/jellyfin.go | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index a241f5f..6a9801d 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/url" - "regexp" // ➕ Add this line right here "strings" "log/slog" From 434945cbc55a59f2f9c65da4b331bda44c0b18fe Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:15:03 -0700 Subject: [PATCH 19/25] Refactor comments for clarity in SearchSongs function --- src/client/jellyfin.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 6a9801d..d70fa69 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -151,14 +151,13 @@ func (c *Jellyfin) CheckRefreshState() bool { func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - // Clean ONLY the problematic typography drift before hitting the API. - // Leave the actual words, brackets, and spaces alone so Jellyfin's index matches. + // Clean problematic typography drift before hitting the API cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "`", "'") cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "“", "\"") cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "”", "\"") - // Revert to the indexed SearchTerm query that successfully found your tracks + // 1. Primary Request: Use the reliable SearchTerm query reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) @@ -171,25 +170,33 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { return err } + // 2. Fallback Request: If SearchTerm returned 0 items, try a direct Name lookup instead + if len(results.Items) == 0 { + fallbackParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&Name=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) + fallbackBody, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+fallbackParam, nil, c.Cfg.Creds.Headers) + if err == nil { + _ = util.ParseResp(fallbackBody, &results) + } + } + normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) rawIncomingArtist := strings.ToLower(track.MainArtist) normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) for _, item := range results.Items { - // Normalize punctuation for the database items we are evaluating itemTitleCleaned := strings.ReplaceAll(item.Name, "’", "'") itemTitleCleaned = strings.ReplaceAll(itemTitleCleaned, "`", "'") normalizedItemTitle := util.NormalizeTitle(itemTitleCleaned) - // 1. Strict MusicBrainz Recording/Track ID Match + // Strict MusicBrainz Recording/Track ID Match musicBrainzMatch := track.MusicBrainzTrackID != "" && (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - // 2. Title Match + // Title Match titleMatch := normalizedItemTitle == normalizedCleanTitle - // 3. Substring-Aware Artist Matching + // Substring-Aware Artist Matching artistMatch := false if normalizedMainArtist != "" { normalizedAlbumArtist := util.NormalizeArtist(item.AlbumArtist) From 316e406cde002325d6ef165a1d8ca6f1a5f1f032 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:23:16 -0700 Subject: [PATCH 20/25] Refactor SearchSongs for improved search logic --- src/client/jellyfin.go | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index d70fa69..b73252c 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -151,15 +151,13 @@ func (c *Jellyfin) CheckRefreshState() bool { func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - // Clean problematic typography drift before hitting the API + // Clean typography drift from the search parameters cleanSearchTitle := strings.ReplaceAll(track.CleanTitle, "’", "'") cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "`", "'") - cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "“", "\"") - cleanSearchTitle = strings.ReplaceAll(cleanSearchTitle, "”", "\"") - - // 1. Primary Request: Use the reliable SearchTerm query - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(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 @@ -170,33 +168,36 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { return err } - // 2. Fallback Request: If SearchTerm returned 0 items, try a direct Name lookup instead - if len(results.Items) == 0 { - fallbackParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&Name=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(strings.TrimSpace(cleanSearchTitle))) - fallbackBody, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+fallbackParam, nil, c.Cfg.Creds.Headers) + // 2. Fail-safe: If Jellyfin returns 0 results, query by the first letter of the title. + // This forces Jellyfin to give us the raw data pool so we can parse it in memory. + if len(results.Items) == 0 && len(cleanSearchTitle) >= 1 { + broadQuery := strings.ToLower(cleanSearchTitle[:1]) + broadParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(broadQuery)) + broadBody, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+broadParam, nil, c.Cfg.Creds.Headers) if err == nil { - _ = util.ParseResp(fallbackBody, &results) + _ = util.ParseResp(broadBody, &results) } } - normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle) + // Use your existing AlnumOnly logic to boil both sides down to a pure comparison blob + // This turns "Rollin’ (Air Raid Vehicle)" and "rollin" both into "rollin" + targetAlnumTitle := util.NormalizeTitle(track.CleanTitle) rawIncomingArtist := strings.ToLower(track.MainArtist) normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) for _, item := range results.Items { - itemTitleCleaned := strings.ReplaceAll(item.Name, "’", "'") - itemTitleCleaned = strings.ReplaceAll(itemTitleCleaned, "`", "'") - normalizedItemTitle := util.NormalizeTitle(itemTitleCleaned) + // Boil the Jellyfin item name down to pure alphanumeric text + currentAlnumItemTitle := util.NormalizeTitle(item.Name) - // Strict MusicBrainz Recording/Track ID Match + // 1. Strict MusicBrainz Recording/Track ID Match musicBrainzMatch := track.MusicBrainzTrackID != "" && (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - // Title Match - titleMatch := normalizedItemTitle == normalizedCleanTitle + // 2. Pure Alphanumeric Title Match (Bypasses all punctuation, casing, and bracket text) + titleMatch := currentAlnumItemTitle == targetAlnumTitle - // Substring-Aware Artist Matching + // 3. Substring-Aware Artist Matching artistMatch := false if normalizedMainArtist != "" { normalizedAlbumArtist := util.NormalizeArtist(item.AlbumArtist) From 70570cac60dbd0efb2886e0a1c2a03c2191faea9 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:32:31 -0700 Subject: [PATCH 21/25] Implement precise word-isolation fallback for search --- src/client/jellyfin.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index b73252c..bec5766 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -167,15 +167,24 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { if err = util.ParseResp(body, &results); err != nil { return err } - - // 2. Fail-safe: If Jellyfin returns 0 results, query by the first letter of the title. - // This forces Jellyfin to give us the raw data pool so we can parse it in memory. - if len(results.Items) == 0 && len(cleanSearchTitle) >= 1 { - broadQuery := strings.ToLower(cleanSearchTitle[:1]) - broadParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(broadQuery)) - broadBody, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+broadParam, nil, c.Cfg.Creds.Headers) - if err == nil { - _ = util.ParseResp(broadBody, &results) + // 2. Precise Word-Isolation Fallback: If primary search returns 0 results, + // fetch using just the first complete word of the song title to avoid index truncation. + if len(results.Items) == 0 && len(cleanSearchTitle) > 0 { + firstWord := strings.Split(cleanSearchTitle, " ")[0] + // Strip common trailing punctuation if the first word is short or holds a symbol + firstWord = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return -1 + }, firstWord) + + if len(firstWord) >= 1 { + 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) + } } } From 32bd95171d085e25d9574cdce37eedce91283d41 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:41:43 -0700 Subject: [PATCH 22/25] Refactor search fallback logic in jellyfin.go Refactor fallback search logic to use the first complete word of the song title and improve punctuation handling. --- src/client/jellyfin.go | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index bec5766..ad849b2 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -167,17 +167,17 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { if err = util.ParseResp(body, &results); err != nil { return err } - // 2. Precise Word-Isolation Fallback: If primary search returns 0 results, - // fetch using just the first complete word of the song title to avoid index truncation. + + // 2. Safe Fallback: If primary search returns 0 results, + // search using just the first complete word of the song title. if len(results.Items) == 0 && len(cleanSearchTitle) > 0 { - firstWord := strings.Split(cleanSearchTitle, " ")[0] - // Strip common trailing punctuation if the first word is short or holds a symbol - firstWord = strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - return r - } - return -1 - }, firstWord) + firstWord := cleanSearchTitle + if spaceIdx := strings.Index(cleanSearchTitle, " "); spaceIdx != -1 { + firstWord = cleanSearchTitle[:spaceIdx] + } + + // Strip common trailing punctuation like apostrophes or dashes from the single word + firstWord = strings.NewReplacer("'", "", "`", "", "-", "", ",", "", "(", "", "[", "").Replace(firstWord) if len(firstWord) >= 1 { broadParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Limit=300&Fields=Path,ProviderIDs", url.QueryEscape(firstWord)) @@ -188,14 +188,11 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { } } - // Use your existing AlnumOnly logic to boil both sides down to a pure comparison blob - // This turns "Rollin’ (Air Raid Vehicle)" and "rollin" both into "rollin" targetAlnumTitle := util.NormalizeTitle(track.CleanTitle) rawIncomingArtist := strings.ToLower(track.MainArtist) normalizedMainArtist := util.NormalizeArtist(util.StripFeat(track.MainArtist)) for _, item := range results.Items { - // Boil the Jellyfin item name down to pure alphanumeric text currentAlnumItemTitle := util.NormalizeTitle(item.Name) // 1. Strict MusicBrainz Recording/Track ID Match @@ -203,7 +200,7 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { (item.ProviderIds.MusicBrainzRecording == track.MusicBrainzTrackID || item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID) - // 2. Pure Alphanumeric Title Match (Bypasses all punctuation, casing, and bracket text) + // 2. Pure Alphanumeric Title Match titleMatch := currentAlnumItemTitle == targetAlnumTitle // 3. Substring-Aware Artist Matching From 9b6996e891915b6b4fcc192beb9ed9fd2b1142f0 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:49:49 -0700 Subject: [PATCH 23/25] Enhance fallback search logic for song titles Refined fallback search logic to isolate the first alphanumeric block from the song title for better search accuracy. --- src/client/jellyfin.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index ad849b2..af9547d 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -168,18 +168,20 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { return err } - // 2. Safe Fallback: If primary search returns 0 results, - // search using just the first complete word of the song title. + // 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 { - firstWord := cleanSearchTitle - if spaceIdx := strings.Index(cleanSearchTitle, " "); spaceIdx != -1 { - firstWord = cleanSearchTitle[:spaceIdx] + 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 + } } - // Strip common trailing punctuation like apostrophes or dashes from the single word - firstWord = strings.NewReplacer("'", "", "`", "", "-", "", ",", "", "(", "", "[", "").Replace(firstWord) - - if len(firstWord) >= 1 { + 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 { From 86593b1d2ae944265cb4702887688f6a6b4589e3 Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:03:29 -0700 Subject: [PATCH 24/25] Delete docker.yml Cleaning up for pull request --- docker.yml | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 docker.yml diff --git a/docker.yml b/docker.yml deleted file mode 100644 index 43a5b6f..0000000 --- a/docker.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Build and push image - -on: - push: - branches: [dev, main, master] - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ghcr.io/gtkashi/explogtk:latest - build-args: VERSION=fork From cb6a5d17ac73134d579c20e235c5e8b7badeff5c Mon Sep 17 00:00:00 2001 From: gtkashi <120768292+gtkashi@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:03:52 -0700 Subject: [PATCH 25/25] Delete .github/workflows/docker.yml Cleanup for pull request --- .github/workflows/docker.yml | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 43a5b6f..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Build and push image - -on: - push: - branches: [dev, main, master] - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ghcr.io/gtkashi/explogtk:latest - build-args: VERSION=fork