diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 0d9b5800d..d9aff37e8 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -205,8 +205,8 @@ var _ = Describe("Provider - TopSongs", func() { // Since there are no MBIDs, loadTracksByMBID should not make any database call // loadTracksByTitle should make a database call for title matching - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -237,7 +237,7 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // Mock the title fallback query (finds song2 by title) - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) diff --git a/core/matcher/doc.go b/core/matcher/doc.go new file mode 100644 index 000000000..cff866799 --- /dev/null +++ b/core/matcher/doc.go @@ -0,0 +1,108 @@ +// Package matcher matches song results from external agents (Last.fm, Deezer, +// etc.) to tracks in the local music library, prioritizing accuracy over recall. +// +// It exposes a single [Matcher] type with two entry points that share the same +// matching algorithm: +// +// - [Matcher.MatchSongs] returns an ordered, deduplicated slice of library +// tracks, capped at a requested count. Use it when presenting "similar +// songs" results to a client. +// - [Matcher.MatchSongsIndexed] returns a map from input-song index to matched +// track, with no deduplication. Use it when the caller needs to correlate +// each result back to its input position (e.g. to attach a per-song +// similarity score). +// +// # Algorithm Overview +// +// Each input song is resolved to its best-matching library track using four +// strategies, applied in priority order. A song matched by a higher-priority +// strategy is never reconsidered by a lower-priority one: +// +// 1. Direct ID match: songs with an ID are matched to a MediaFile by ID. +// 2. MusicBrainz Recording ID (MBID) match: songs with an MBID are matched to +// tracks with the same mbz_recording_id. +// 3. ISRC match: songs with an ISRC are matched to tracks carrying that ISRC tag. +// 4. Title+Artist fuzzy match: remaining songs are matched by fuzzy string +// comparison with metadata-specificity scoring (see below). +// +// Priority order is ID > MBID > ISRC > Title+Artist, so more reliable +// identifiers always take precedence over fuzzy text matching. Missing tracks +// (those no longer present on disk) are never matched. +// +// # Fuzzy Matching Details +// +// Title+artist matching uses Jaro-Winkler similarity, with a threshold +// configurable via conf.Server.Matcher.FuzzyThreshold (default 85%). A library +// track must clear the title threshold to be considered. Candidates that clear +// it are ranked by, in order: +// +// 1. Title similarity (Jaro-Winkler score, 0.0–1.0) +// 2. Duration proximity (closer duration scores higher; 1.0 when the agent +// reports no duration) +// 3. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; +// prioritizes tracks that are starred or rated >= 4) +// 4. Specificity level (0–5, based on metadata precision; higher is better) +// 5. Album similarity (Jaro-Winkler, as the final tiebreaker) +// +// The specificity levels, from most to least specific, are: +// +// Level 5: Title + Artist MBID + Album MBID +// Level 4: Title + Artist MBID + Album name (fuzzy) +// Level 3: Title + Artist name + Album name (fuzzy) +// Level 2: Title + Artist MBID +// Level 1: Title + Artist name +// Level 0: Title only +// +// The title phase always requires an agent artist to scope the library query, so +// Level 0 does not mean "no artist": it applies when a candidate matches on title +// but its own artist differs from the query's (e.g. a cover or a featured-artist +// credit), leaving the title as the only shared field. +// +// Each input song is scored independently, so two songs with the same title and +// artist but different durations can resolve to different library tracks (each +// matches the track closest to its own duration). +// +// # Examples +// +// All examples below exercise the title+artist phase, where the interesting +// behavior lives. (Identifier phases — ID, MBID, ISRC — are exact lookups that +// always win over fuzzy matching; they need no illustration.) +// +// Title threshold — a near-miss title still matches; an exact-only threshold +// rejects it: +// +// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} +// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} +// With threshold 85%: match succeeds (similarity ~0.87) +// With threshold 100%: no match (not an exact title) +// +// Specificity ranking — among candidates that clear the title threshold, a +// better album match wins: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: +// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"} // Level 1 +// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 +// Result: t2 (Level 3 beats Level 1 on the album match) +// +// Duration tiebreak — with title and artist equal, the closest duration wins, +// so two near-identical input songs can resolve to different tracks: +// +// Agent returns: +// {Name: "Untitled", Artist: "Interpol", Duration: 245000} // 4:05 +// {Name: "Untitled", Artist: "Interpol", Duration: 600000} // 10:00 (a live take) +// Library has: +// {ID: "studio", Title: "Untitled", Artist: "Interpol", Duration: 248} // 4:08 +// {ID: "live", Title: "Untitled", Artist: "Interpol", Duration: 602} // 10:02 +// Result: studio for the first song, live for the second +// +// Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a +// starred (or rating >= 4) track is preferred even over a more specific match, +// because the preferred flag outranks specificity: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: +// {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 +// {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred +// Result: starred (the preferred flag outranks the better album match) +package matcher diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 54c2a368e..8b52cbad2 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -8,6 +8,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" "github.com/xrash/smetrics" @@ -23,176 +24,73 @@ func New(ds model.DataStore) *Matcher { return &Matcher{ds: ds} } -// MatchSongs matches agent song results to local library tracks using a multi-phase -// matching algorithm that prioritizes accuracy over recall. +// MatchSongs matches agent songs to library tracks and returns up to count +// tracks in the input's order. See the package documentation for the matching +// algorithm. // -// # Algorithm Overview -// -// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the -// local music library using four matching strategies in priority order: -// -// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID -// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with -// matching mbz_recording_id -// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag -// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison -// with metadata specificity scoring -// -// # Matching Priority -// -// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist. -// This ensures that more reliable identifiers take precedence over fuzzy text matching. -// -// # Fuzzy Matching Details -// -// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable -// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by: -// -// 1. Title similarity (Jaro-Winkler score, 0.0-1.0) -// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is -// starred or has rating >= 4) -// 4. Specificity level (0-5, based on metadata precision): -// - Level 5: Title + Artist MBID + Album MBID (most specific) -// - Level 4: Title + Artist MBID + Album name (fuzzy) -// - Level 3: Title + Artist name + Album name (fuzzy) -// - Level 2: Title + Artist MBID -// - Level 1: Title + Artist name -// - Level 0: Title only -// 5. Album similarity (Jaro-Winkler, as final tiebreaker) -// -// # Examples -// -// Example 1 - MBID Priority: -// -// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"} -// Library has: [ -// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"}, -// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, -// ] -// Result: t1 (MBID match takes priority over title+artist) -// -// Example 2 - ISRC Priority: -// -// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"} -// Library has: [ -// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}}, -// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, -// ] -// Result: t1 (ISRC match takes priority over title+artist) -// -// Example 3 - Specificity Ranking: -// -// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} -// Library has: [ -// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1 -// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3 -// ] -// Result: t2 (Level 3 beats Level 1 due to album match) -// -// Example 4 - Fuzzy Title Matching: -// -// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} -// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} -// With threshold=85%: Match succeeds (similarity ~0.87) -// With threshold=100%: No match (not exact) -// -// # Parameters -// -// - ctx: Context for database operations -// - songs: Slice of agent.Song results from external providers -// - count: Maximum number of matches to return -// -// # Returns -// -// Returns up to 'count' MediaFiles from the library that best match the input songs, -// preserving the original order from the agent. Songs that cannot be matched are skipped. +// Each library track appears at most once, unless the same input song is +// repeated: identical input songs intentionally yield repeated output tracks, +// while distinct songs that resolve to the same track are deduplicated. Songs +// that cannot be matched are skipped. func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { if len(songs) == 0 { return nil, nil } - - byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + matches, err := m.resolveMatches(ctx, songs) if err != nil { return nil, err } - return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil + return orderAndDedup(songs, matches, count), nil } -// MatchSongsIndexed matches agent song results to local library tracks and returns a map -// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. -// This preserves original indices, allowing callers to correlate results back to the input slice. +// MatchSongsIndexed matches agent songs to library tracks and returns a map from +// input-song index to matched track, letting callers correlate results back to +// the input slice. Unmatched songs are omitted from the map. Unlike MatchSongs, +// results are not deduplicated. See the package documentation for the matching +// algorithm. func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { if len(songs) == 0 { return nil, nil } + return m.resolveMatches(ctx, songs) +} - byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) - if err != nil { - return nil, err - } - +// resolveMatches resolves each input song to its best-matching library track, +// keyed by the song's index. Loaders run in priority order (ID > MBID > ISRC > +// Title); each only fills indices not already matched by a higher-priority loader. +func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { result := make(map[int]model.MediaFile, len(songs)) - for i, t := range songs { - if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { - result[i] = mf + if err := m.matchByID(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by ID: %w", err) + } + if err := m.matchByMBID(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by MBID: %w", err) + } + if err := m.matchByISRC(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by ISRC: %w", err) + } + // The title phase is best-effort: a DB failure there must not discard the exact + // matches already found by the higher-priority phases. Only surface it as fatal + // when nothing matched at all. + if err := m.matchByTitle(ctx, songs, result); err != nil { + if len(result) == 0 { + return nil, fmt.Errorf("failed to match tracks by title: %w", err) } + log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err) } return result, nil } -func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { - byID, err = m.loadTracksByID(ctx, songs) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - byMBID, err = m.loadTracksByMBID(ctx, songs, byID) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) - } - byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) - } - return byID, byMBID, byISRC, byTitle, nil -} - -// songMatchedIn checks if a song has already been matched in any of the provided match maps. -func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { - _, found := lookupByIdentifiers(s, priorMatches...) - return found -} - -// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. -func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { - keys := []string{s.ID, s.MBID, s.ISRC} - for _, m := range maps { - for _, key := range keys { - if key != "" { - if mf, ok := m[key]; ok && mf.ID != "" { - return mf, true - } - } - } - } - return model.MediaFile{}, false -} - -// loadTracksByID fetches MediaFiles from the library using direct ID matching. -func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { +// matchByID fills result with direct ID matches. +func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var ids []string for _, s := range songs { if s.ID != "" { ids = append(ids, s.ID) } } - matches := map[string]model.MediaFile{} if len(ids) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -201,27 +99,37 @@ func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[ }, }) if err != nil { - return matches, err + return err } + byID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { - if _, ok := matches[mf.ID]; !ok { - matches[mf.ID] = mf + byID[mf.ID] = mf // media_file.id is unique, so no dedup needed + } + for i, s := range songs { + if s.ID == "" { + continue + } + if mf, ok := byID[s.ID]; ok { + result[i] = mf } } - return matches, nil + return nil } -// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. -func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// matchByMBID fills result with MusicBrainz Recording ID matches, skipping +// songs already matched by a higher-priority loader. +func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var mbids []string - for _, s := range songs { - if s.MBID != "" && !songMatchedIn(s, priorMatches...) { + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.MBID != "" { mbids = append(mbids, s.MBID) } } - matches := map[string]model.MediaFile{} if len(mbids) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -230,45 +138,72 @@ func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, pri }, }) if err != nil { - return matches, err + return err } + byMBID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { if id := mf.MbzRecordingID; id != "" { - if _, ok := matches[id]; !ok { - matches[id] = mf + if _, ok := byMBID[id]; !ok { + byMBID[id] = mf } } } - return matches, nil + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.MBID == "" { + continue + } + if mf, ok := byMBID[s.MBID]; ok { + result[i] = mf + } + } + return nil } -// loadTracksByISRC fetches MediaFiles from the library using ISRC matching. -func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// matchByISRC fills result with ISRC tag matches, skipping songs already +// matched by a higher-priority loader. +func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var isrcs []string - for _, s := range songs { - if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.ISRC != "" { isrcs = append(isrcs, s.ISRC) } } - matches := map[string]model.MediaFile{} if len(isrcs) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, Sort: "starred desc, rating desc, year asc, compilation asc", }) if err != nil { - return matches, err + return err } + byISRC := make(map[string]model.MediaFile, len(res)) for _, mf := range res { for _, isrc := range mf.Tags.Values(model.TagISRC) { - if _, ok := matches[isrc]; !ok { - matches[isrc] = mf + if _, ok := byISRC[isrc]; !ok { + byISRC[isrc] = mf } } } - return matches, nil + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.ISRC == "" { + continue + } + if mf, ok := byISRC[s.ISRC]; ok { + result[i] = mf + } + } + return nil } // songQuery represents a normalized query for matching a song to library tracks. @@ -308,9 +243,8 @@ func (s matchScore) betterThan(other matchScore) bool { } // sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization -// when the same track is scored against multiple queries in the inner loop. The `mf` field -// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist -// sanitized slice. +// when the same track is scored against multiple queries. The `mf` field is a pointer to avoid +// copying the large MediaFile struct into each entry of the sanitized slice. type sanitizedTrack struct { mf *model.MediaFile title string @@ -329,6 +263,12 @@ func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { // computeSpecificityLevel determines how well query metadata matches a track (0-5). // The track's title, artist, and album fields must be pre-sanitized. +// +// TODO: the artist-MBID levels (5, 4, 2) read the deprecated MediaFile.MbzArtistID +// column, which is not populated — the artist MBID lives in the artist table and is +// only hydrated by GetWithParticipants, not the bulk GetAll path used here. As a +// result those levels never fire. To make them work, hydrate the artist participant +// (or denormalize mbz_artist_id onto media_file) so t.mf carries the artist MBID. func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { if q.artistMBID != "" && q.albumMBID != "" && t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { @@ -348,56 +288,86 @@ func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float if q.artist != "" && t.artist == q.artist { return 1 } - if t.title == q.title { - return 0 - } - return -1 + return 0 } -// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. -func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { - queries := m.buildTitleQueries(songs, priorMatches...) - if len(queries) == 0 { - return map[string]model.MediaFile{}, nil +// indexedQuery pairs a normalized songQuery with the index of the input song +// it came from, so title matches can be written back to result by index. +type indexedQuery struct { + index int + query songQuery +} + +// matchByTitle fills result with fuzzy title+artist matches, skipping songs +// already matched by a higher-priority loader. +func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { + byArtist := map[string][]indexedQuery{} + for i, s := range songs { + if _, done := result[i]; done { + continue + } + artist := str.SanitizeFieldForSortingNoArticle(s.Artist) + if artist == "" { + continue // title matching needs an artist to scope the library query + } + q := songQuery{ + title: str.SanitizeFieldForSorting(s.Name), + artist: artist, + artistMBID: s.ArtistMBID, + album: str.SanitizeFieldForSorting(s.Album), + albumMBID: s.AlbumMBID, + durationMs: s.Duration, + } + byArtist[artist] = append(byArtist[artist], indexedQuery{index: i, query: q}) + } + if len(byArtist) == 0 { + return nil + } + + // One batched query (order_artist_name IN ...) instead of one per artist: on a + // large library the per-query overhead dominates, so this is the main cost saver. + artists := make([]string, 0, len(byArtist)) + for artist := range byArtist { + artists = append(artists, artist) + } + tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"order_artist_name": artists}, + squirrel.Eq{"missing": false}, + }, + Sort: "starred desc, rating desc, year asc, compilation asc", + }) + if err != nil { + return err + } + + // Key on order_artist_name — the exact field the query filtered on, which matches + // how byArtist is keyed. A track's display Artist can differ (collaborations, + // "feat." credits), so re-deriving from Artist would misbucket. This reads the + // deprecated MediaFile.OrderArtistName column because the bulk GetAll path does + // not hydrate participant detail (only {id, name}), so the participant's order + // name is empty here; the column is the only populated source. + tracksByArtist := make(map[string][]sanitizedTrack, len(byArtist)) + for i := range tracks { + key := tracks[i].OrderArtistName + if key == "" { + key = str.SanitizeFieldForSortingNoArticle(tracks[i].Artist) + } + tracksByArtist[key] = append(tracksByArtist[key], newSanitizedTrack(&tracks[i])) } threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 - - byArtist := map[string][]songQuery{} - for _, q := range queries { - if q.artist != "" { - byArtist[q.artist] = append(byArtist[q.artist], q) - } - } - - matches := map[string]model.MediaFile{} - for artist, artistQueries := range byArtist { - tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"order_artist_name": artist}, - squirrel.Eq{"missing": false}, - }, - Sort: "starred desc, rating desc, year asc, compilation asc", - }) - if err != nil { - continue - } - - sanitized := make([]sanitizedTrack, len(tracks)) - for i := range tracks { - sanitized[i] = newSanitizedTrack(&tracks[i]) - } - - for _, q := range artistQueries { - if mf, found := m.findBestMatch(q, sanitized, threshold); found { - key := q.title + "|" + q.artist - if _, exists := matches[key]; !exists { - matches[key] = mf - } + for artist, queries := range byArtist { + sanitized := tracksByArtist[artist] + // Each song is matched independently by index, so two songs with the same + // (title, artist) but different durations can resolve to different tracks. + for _, iq := range queries { + if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { + result[iq.index] = mf } } } - return matches, nil + return nil } // durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration @@ -417,6 +387,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t bestScore := matchScore{titleSimilarity: -1} found := false + preferStarred := conf.Server.Matcher.PreferStarred for _, t := range sanitizedTracks { titleSim := similarityRatio(q.title, t.title) @@ -432,7 +403,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t score := matchScore{ titleSimilarity: titleSim, durationProximity: durationProximity(q.durationMs, t.mf.Duration), - preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf), + preferredMatch: preferStarred && isPreferredTrack(t.mf), albumSimilarity: albumSim, specificityLevel: computeSpecificityLevel(q, t, threshold), } @@ -450,66 +421,34 @@ func isPreferredTrack(mf *model.MediaFile) bool { return mf.Starred || mf.Rating >= 4 } -// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. -func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { - var queries []songQuery - for _, s := range songs { - if songMatchedIn(s, priorMatches...) { - continue - } - queries = append(queries, songQuery{ - title: str.SanitizeFieldForSorting(s.Name), - artist: str.SanitizeFieldForSortingNoArticle(s.Artist), - artistMBID: s.ArtistMBID, - album: str.SanitizeFieldForSorting(s.Album), - albumMBID: s.AlbumMBID, - durationMs: s.Duration, - }) - } - return queries -} - -// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching -// library tracks using priority order: ID > MBID > ISRC > title+artist. -func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { +// orderAndDedup builds the final ordered result from the per-index matches, +// applying the count limit and deduplication. A library track is added at most +// once unless the same input song appears more than once (callers rely on that +// 1:1 positional behavior for identical duplicate inputs). +func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count int) model.MediaFiles { mfs := make(model.MediaFiles, 0, len(songs)) - addedBy := make(map[string]agents.Song, len(songs)) + addedBy := make(map[string]int, len(songs)) - for _, t := range songs { + for i, s := range songs { if len(mfs) == count { break } - - mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist) + mf, found := matches[i] if !found { continue } - - if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - if t != prevSong { + if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded { + if s != songs[prevIdx] { continue } } else { - addedBy[mf.ID] = t + addedBy[mf.ID] = i } - mfs = append(mfs, mf) } return mfs } -// findMatchingTrack looks up a song in the match maps using priority order. -func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { - if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { - return mf, true - } - key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) - if mf, ok := byTitleArtist[key]; ok { - return mf, true - } - return model.MediaFile{}, false -} - // similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. func similarityRatio(a, b string) float64 { if a == b { diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 42e3ec88d..0d08897d1 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -53,24 +53,30 @@ var _ = Describe("Matcher", func() { Return(matches, nil).Once() } - // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return - // early without hitting the DB) don't cause test failures for unexpected calls. Call - // this after expect*Phase for the phases the test actually wants to verify. - allowOtherPhases := func() { + // allowIdentifierPhases installs .Maybe() catch-alls for the ID/MBID/ISRC phases so + // tests that only care about the title phase don't fail on those unexpected calls. + allowIdentifierPhases := func() { mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). Return(model.MediaFiles{}, nil).Maybe() + } + + // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return + // early without hitting the DB) don't cause test failures for unexpected calls. Call + // this after expect*Phase for the phases the test actually wants to verify. + allowOtherPhases := func() { + allowIdentifierPhases() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). Return(model.MediaFiles{}, nil).Maybe() } - // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise - // the title+artist phase. The title phase uses .Maybe() because it may short-circuit - // when no songs have an artist. - setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) { + // allowTitlePhase is a convenience for fuzzy-match tests that only exercise the + // title+artist phase. It uses .Maybe() because the phase may short-circuit when no + // songs have an artist. + allowTitlePhase := func(artistTracks model.MediaFiles) { mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). Return(artistTracks, nil).Maybe() } @@ -141,7 +147,7 @@ var _ = Describe("Matcher", func() { titleMatch := model.MediaFile{ ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", } - setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) + allowTitlePhase(model.MediaFiles{titleMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -156,7 +162,7 @@ var _ = Describe("Matcher", func() { fuzzyMatch := model.MediaFile{ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -171,7 +177,7 @@ var _ = Describe("Matcher", func() { differentTracks := model.MediaFiles{ {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, } - setupTitleOnlyExpectations(differentTracks) + allowTitlePhase(differentTracks) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -188,7 +194,7 @@ var _ = Describe("Matcher", func() { libraryTrack := model.MediaFile{ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -204,7 +210,7 @@ var _ = Describe("Matcher", func() { libraryTrack := model.MediaFile{ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -247,7 +253,7 @@ var _ = Describe("Matcher", func() { {ID: "b", Title: "Song B", Artist: "Artist"}, {ID: "c", Title: "Song C", Artist: "Artist"}, } - setupTitleOnlyExpectations(tracks) + allowTitlePhase(tracks) result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -261,6 +267,65 @@ var _ = Describe("Matcher", func() { Expect(result).To(BeEmpty()) }) }) + + Context("artist grouping", func() { + It("groups title-phase tracks by order_artist_name, not display Artist", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Daft Punk"}, + } + // Display Artist differs from the query artist; only OrderArtistName + // matches, so grouping must key on it (a "feat." credit, collaboration, etc.). + track := model.MediaFile{ + ID: "oan-track", Title: "Song A", + Artist: "Daft Punk feat. Pharrell", OrderArtistName: "daft punk", + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("oan-track")) + }) + }) + + // These tests register their own order_artist_name expectation per-test (to inject + // an error), so they use allowIdentifierPhases — NOT allowOtherPhases, which would + // add a .Maybe() title-phase catch-all that masks the injected error. + Context("title phase DB errors", func() { + It("returns an error when the title query fails and nothing else matched", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist One"}, + {Name: "Song B", Artist: "Artist Two"}, + } + allowIdentifierPhases() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(nil, errors.New("db down")) + + _, err := m.MatchSongs(ctx, songs, 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("db down")) + }) + + It("keeps exact-phase matches when the title query fails", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Exact Song", Artist: "Exact Artist"}, + {Name: "Fuzzy Song", Artist: "Fuzzy Artist"}, + } + idMatch := model.MediaFile{ID: "track-1", Title: "Exact Song", Artist: "Exact Artist"} + expectIDPhase(model.MediaFiles{idMatch}) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(nil, errors.New("db down")) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-1")) + }) + }) }) Describe("MatchSongsIndexed", func() { @@ -328,7 +393,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -348,7 +413,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -368,7 +433,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song", Artist: "Depeche Mode"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -382,7 +447,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song"}, } - setupTitleOnlyExpectations(model.MediaFiles{}) + allowTitlePhase(model.MediaFiles{}) result, err := m.MatchSongs(ctx, songs, 5) @@ -401,7 +466,7 @@ var _ = Describe("Matcher", func() { {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, } - setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) + allowTitlePhase(model.MediaFiles{cover1, cover2, cover3}) result, err := m.MatchSongs(ctx, songs, 5) @@ -429,7 +494,7 @@ var _ = Describe("Matcher", func() { {Name: "Song B", Artist: "Artist Two"}, } - setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + allowTitlePhase(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -452,7 +517,7 @@ var _ = Describe("Matcher", func() { {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -471,7 +536,7 @@ var _ = Describe("Matcher", func() { {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -492,7 +557,7 @@ var _ = Describe("Matcher", func() { {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -512,7 +577,7 @@ var _ = Describe("Matcher", func() { {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -540,7 +605,7 @@ var _ = Describe("Matcher", func() { ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -560,7 +625,7 @@ var _ = Describe("Matcher", func() { ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -580,7 +645,7 @@ var _ = Describe("Matcher", func() { ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch, exactMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -601,7 +666,7 @@ var _ = Describe("Matcher", func() { ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, } - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) + allowTitlePhase(model.MediaFiles{albumMatch, starredTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -622,7 +687,7 @@ var _ = Describe("Matcher", func() { ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, } - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) + allowTitlePhase(model.MediaFiles{albumMatch, ratedTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -648,7 +713,7 @@ var _ = Describe("Matcher", func() { ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, } - setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongDuration, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -665,7 +730,7 @@ var _ = Describe("Matcher", func() { ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, } - setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) + allowTitlePhase(model.MediaFiles{closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -685,7 +750,7 @@ var _ = Describe("Matcher", func() { ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, } - setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) + allowTitlePhase(model.MediaFiles{farDuration, closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -702,7 +767,7 @@ var _ = Describe("Matcher", func() { ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, } - setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) + allowTitlePhase(model.MediaFiles{differentDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -722,7 +787,7 @@ var _ = Describe("Matcher", func() { ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, } - setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) + allowTitlePhase(model.MediaFiles{differentTitle, correctTitle}) result, err := m.MatchSongs(ctx, songs, 5) @@ -739,7 +804,7 @@ var _ = Describe("Matcher", func() { ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, } - setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) + allowTitlePhase(model.MediaFiles{anyTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -756,7 +821,7 @@ var _ = Describe("Matcher", func() { ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, } - setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) + allowTitlePhase(model.MediaFiles{shortTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -764,6 +829,28 @@ var _ = Describe("Matcher", func() { Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("short")) }) + + It("matches same title+artist songs to their own closest-duration track", func() { + songs := []agents.Song{ + {Name: "Same Song", Artist: "Same Artist", Duration: 180000}, + {Name: "Same Song", Artist: "Same Artist", Duration: 240000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Same Song", Artist: "Same Artist", Duration: 180.0, + } + longTrack := model.MediaFile{ + ID: "long", Title: "Same Song", Artist: "Same Artist", Duration: 240.0, + } + + allowTitlePhase(model.MediaFiles{shortTrack, longTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("short")) + Expect(result[1].ID).To(Equal("long")) + }) }) Describe("deduplication edge cases", func() { @@ -782,7 +869,7 @@ var _ = Describe("Matcher", func() { ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -802,7 +889,7 @@ var _ = Describe("Matcher", func() { trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) + allowTitlePhase(model.MediaFiles{trackA, trackB, trackC}) result, err := m.MatchSongs(ctx, songs, 5) @@ -823,7 +910,7 @@ var _ = Describe("Matcher", func() { trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) + allowTitlePhase(model.MediaFiles{trackA, trackB}) result, err := m.MatchSongs(ctx, songs, 2)