diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index 388368d8..f11d5182 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -120,8 +120,8 @@ func artistsMatch(expectedArtist, foundArtist string) bool { return true } - expectedArtists := splitArtists(normExpected) - foundArtists := splitArtists(normFound) + expectedArtists := splitArtists(expectedArtist) + foundArtists := splitArtists(foundArtist) for _, expected := range expectedArtists { for _, found := range foundArtists { @@ -142,20 +142,21 @@ func artistsMatch(expectedArtist, foundArtist string) bool { } func splitArtists(artists string) []string { - normalized := artists + normalized := strings.ToLower(artists) normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|") normalized = strings.ReplaceAll(normalized, " ft. ", "|") normalized = strings.ReplaceAll(normalized, " ft ", "|") normalized = strings.ReplaceAll(normalized, " & ", "|") normalized = strings.ReplaceAll(normalized, " and ", "|") - normalized = strings.ReplaceAll(normalized, ", ", "|") + normalized = strings.ReplaceAll(normalized, ",", "|") + normalized = strings.ReplaceAll(normalized, ";", "|") normalized = strings.ReplaceAll(normalized, " x ", "|") parts := strings.Split(normalized, "|") result := make([]string, 0, len(parts)) for _, part := range parts { - trimmed := strings.TrimSpace(part) + trimmed := normalizeLooseArtistName(part) if trimmed != "" { result = append(result, trimmed) } @@ -253,6 +254,26 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return false } +func trackTitlesMatch(expectedTitle, foundTitle string) bool { + expected := normalizeLooseTitle(expectedTitle) + found := normalizeLooseTitle(foundTitle) + if expected != "" && expected == found { + return true + } + + // Version words identify recordings; punctuation around them does not. + for _, title := range []string{expected, found} { + for _, word := range strings.Fields(title) { + switch word { + case "mix", "remix", "live", "acoustic", "demo", "instrumental", + "karaoke", "edit", "extended", "slowed", "sped": + return false + } + } + } + return titlesMatch(expectedTitle, foundTitle) +} + func extractCoreTitle(title string) string { parenIdx := strings.Index(title, "(") bracketIdx := strings.Index(title, "[") @@ -425,7 +446,7 @@ func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPre } if req.TrackName != "" && resolved.Title != "" && - !titlesMatch(req.TrackName, resolved.Title) { + !trackTitlesMatch(req.TrackName, resolved.Title) { GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n", logPrefix, req.TrackName, resolved.Title) return false diff --git a/go_backend/title_match_utils_test.go b/go_backend/title_match_utils_test.go index a1910d1e..9228dfda 100644 --- a/go_backend/title_match_utils_test.go +++ b/go_backend/title_match_utils_test.go @@ -171,6 +171,65 @@ func TestTitlesMatch_SeparatorVariants(t *testing.T) { } } +func TestTrackMatchingPreservesArtistCreditBoundaries(t *testing.T) { + req := DownloadRequest{ + TrackName: "Signal - Remix", ArtistName: "Composer, Lead Singer & Lyric Writer", + AlbumName: "Original Soundtrack", DurationMS: 234000, + } + for _, artist := range []string{ + "Composer, Lead Singer, Guest Writer", + "Lead Singer & Composer", + "GUEST WRITER; LEAD SINGER", + } { + t.Run(artist, func(t *testing.T) { + resolved := resolvedTrackInfo{ + Title: "Signal (Remix)", ArtistName: artist, + AlbumName: "Original Soundtrack", Duration: 234, + } + if !trackMatchesRequest(req, resolved, "test") { + t.Fatal("matching recording rejected because contributor credits differ") + } + tracks := []ExtTrackMetadata{{ + Name: resolved.Title, Artists: artist, AlbumName: "Collection", + DurationMS: 234000, ProviderID: "provider", + }} + if selectBestMetadataEnrichmentTrack(req, tracks) == nil { + t.Fatal("matching recording rejected during metadata enrichment") + } + }) + } +} + +func TestTrackMetadataTolerancePreservesRecordingIdentity(t *testing.T) { + req := DownloadRequest{ + TrackName: "Signal - Remix", ArtistName: "Composer, Lead Singer & Lyric Writer", + AlbumName: "Original Soundtrack", DurationMS: 234000, + } + for _, tc := range []struct { + name string + title string + artist string + duration int + want bool + }{ + {"punctuation", "Signal (Remix)", req.ArtistName, 234, true}, + {"original", "Signal", req.ArtistName, 234, false}, + {"named mix", "Signal (Club Mix)", req.ArtistName, 234, false}, + {"live remix", "Signal (Remix Live)", req.ArtistName, 234, false}, + {"other artist", "Signal (Remix)", "Unrelated Singer", 234, false}, + {"other duration", "Signal (Remix)", "Composer, Lead Singer, Guest Writer", 305, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got := trackMatchesRequest(req, resolvedTrackInfo{ + Title: tc.title, ArtistName: tc.artist, AlbumName: req.AlbumName, Duration: tc.duration, + }, "test") + if got != tc.want { + t.Fatalf("trackMatchesRequest = %v, want %v", got, tc.want) + } + }) + } +} + func TestTitlesMatch_EmojiStrict(t *testing.T) { if titlesMatch("🪐", "Higher Power") { t.Fatal("expected emoji title not to match unrelated textual title")