package matcher
import (
"context"
"fmt"
"maps"
"math"
"slices"
"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/slice"
"github.com/navidrome/navidrome/utils/str"
"github.com/xrash/smetrics"
)
type Matcher struct {
ds model.DataStore
}
func New(ds model.DataStore) *Matcher {
return &Matcher{ds: ds}
}
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
if len(songs) == 0 {
return nil, nil
}
matches, err := m.resolveMatches(ctx, songs)
if err != nil {
return nil, err
}
return orderAndDedup(songs, matches, count), nil
}
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)
}
func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
result := make(map[int]model.MediaFile, len(songs))
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)
}
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) 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)
}
}
if len(ids) == 0 {
return nil
}
res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.id": ids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return err
}
byID := make(map[string]model.MediaFile, len(res))
for _, mf := range res {
byID[mf.ID] = mf
}
for i, s := range songs {
if s.ID == "" {
continue
}
if mf, ok := byID[s.ID]; ok {
result[i] = mf
}
}
return nil
}
func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
var mbids []string
for i, s := range songs {
if _, done := result[i]; done {
continue
}
if s.MBID != "" {
mbids = append(mbids, s.MBID)
}
}
if len(mbids) == 0 {
return nil
}
res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"mbz_recording_id": mbids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return err
}
byMBID := make(map[string]model.MediaFile, len(res))
for _, mf := range res {
if id := mf.MbzRecordingID; id != "" {
if _, ok := byMBID[id]; !ok {
byMBID[id] = mf
}
}
}
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
}
func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
var isrcs []string
for i, s := range songs {
if _, done := result[i]; done {
continue
}
if s.ISRC != "" {
isrcs = append(isrcs, s.ISRC)
}
}
if len(isrcs) == 0 {
return nil
}
res, err := m.ds.MediaFile().GetAllByTags(ctx, model.TagISRC, isrcs, model.QueryOptions{
Filters: squirrel.Eq{"missing": false},
Sort: "starred desc, rating desc, year asc, compilation asc",
})
if err != nil {
return err
}
byISRC := make(map[string]model.MediaFile, len(res))
for _, mf := range res {
for _, isrc := range mf.Tags.Values(model.TagISRC) {
if _, ok := byISRC[isrc]; !ok {
byISRC[isrc] = mf
}
}
}
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
}
type queryArtist struct {
id string
name string
mbid string
}
type songQuery struct {
title string
artists []queryArtist
album string
albumMBID string
durationMs uint32
}
type matchScore struct {
titleSimilarity float64
durationProximity float64
preferredMatch bool
specificityLevel int
artistOverlap int
albumSimilarity float64
}
func (s matchScore) betterThan(other matchScore) bool {
if s.titleSimilarity != other.titleSimilarity {
return s.titleSimilarity > other.titleSimilarity
}
if s.durationProximity != other.durationProximity {
return s.durationProximity > other.durationProximity
}
if s.specificityLevel != other.specificityLevel {
return s.specificityLevel > other.specificityLevel
}
if s.artistOverlap != other.artistOverlap {
return s.artistOverlap > other.artistOverlap
}
if s.preferredMatch != other.preferredMatch {
return s.preferredMatch
}
return s.albumSimilarity > other.albumSimilarity
}
type sanitizedTrack struct {
mf *model.MediaFile
title string
artist string
album string
artistIDs map[string]struct{}
artistMBIDs map[string]struct{}
}
func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack {
return sanitizedTrack{
mf: mf,
title: str.SanitizeFieldForSorting(mf.Title),
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
album: str.SanitizeFieldForSorting(mf.Album),
artistIDs: artistIDs,
artistMBIDs: artistMBIDs,
}
}
func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
best := 0
albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold
for _, a := range q.artists {
_, idMember := t.artistIDs[a.id]
_, mbidMember := t.artistMBIDs[a.mbid]
identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember)
level := 0
switch {
case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID:
level = 5
case identity && q.album != "" && albumOK:
level = 4
case a.name != "" && q.album != "" && t.artist == a.name && albumOK:
level = 3
case identity:
level = 2
case a.name != "" && t.artist == a.name:
level = 1
}
if level > best {
best = level
}
}
return best
}
type indexedQuery struct {
index int
query songQuery
}
func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
queries := groupQueries(songs, result)
if len(queries) == 0 {
return nil
}
resolved, err := m.resolveArtists(ctx, queries)
if err != nil || len(resolved.allIDs) == 0 {
return err
}
tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs)
if err != nil {
return err
}
tracksByQuery := resolved.bucketTracks(tracks)
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
for _, iq := range queries {
sanitized := tracksByQuery[iq.index]
if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found {
result[iq.index] = mf
}
}
return nil
}
func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery {
var queries []indexedQuery
for i, s := range songs {
if _, done := result[i]; done {
continue
}
var artists []queryArtist
for _, a := range s.Artists {
name := str.SanitizeFieldForSortingNoArticle(a.Name)
if a.ID == "" && name == "" && a.MBID == "" {
continue
}
artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID})
}
if len(artists) == 0 {
continue
}
queries = append(queries, indexedQuery{index: i, query: songQuery{
title: str.SanitizeFieldForSorting(s.Name),
artists: artists,
album: str.SanitizeFieldForSorting(s.Album),
albumMBID: s.AlbumMBID,
durationMs: s.Duration,
}})
}
return queries
}
type resolvedArtists struct {
byQuery map[int]map[string]struct{}
mbid map[string]string
allIDs []string
}
func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (resolvedArtists, error) {
res := resolvedArtists{
byQuery: make(map[int]map[string]struct{}, len(queries)),
mbid: make(map[string]string),
}
allIDs := map[string]struct{}{}
type pendingArtist struct {
name, mbid string
query int
}
var pending []pendingArtist
for _, iq := range queries {
for _, a := range iq.query.artists {
if a.id != "" {
addToSet(res.byQuery, iq.index, a.id)
allIDs[a.id] = struct{}{}
continue
}
pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index})
}
}
nameToQueries := map[string][]int{}
mbidToQueries := map[string][]int{}
for _, p := range pending {
if p.name != "" {
nameToQueries[p.name] = append(nameToQueries[p.name], p.query)
}
if p.mbid != "" {
mbidToQueries[p.mbid] = append(mbidToQueries[p.mbid], p.query)
}
}
var filter squirrel.Or
if len(nameToQueries) > 0 {
filter = append(filter, squirrel.Eq{"order_artist_name": slices.Collect(maps.Keys(nameToQueries))})
}
if len(mbidToQueries) > 0 {
filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))})
}
if len(allIDs) > 0 {
filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))})
}
if len(filter) > 0 {
artists, err := m.ds.Artist().GetAll(ctx, model.QueryOptions{Filters: filter})
if err != nil {
return resolvedArtists{}, err
}
for _, a := range artists {
res.mbid[a.ID] = a.MbzArtistID
allIDs[a.ID] = struct{}{}
for _, idx := range nameToQueries[a.OrderArtistName] {
addToSet(res.byQuery, idx, a.ID)
}
if a.MbzArtistID != "" {
for _, idx := range mbidToQueries[a.MbzArtistID] {
addToSet(res.byQuery, idx, a.ID)
}
}
}
}
res.allIDs = slices.Collect(maps.Keys(allIDs))
return res, nil
}
func addToSet(m map[int]map[string]struct{}, k int, v string) {
if m[k] == nil {
m[k] = map[string]struct{}{}
}
m[k][v] = struct{}{}
}
type scoredTrack struct {
sanitizedTrack
overlap int
}
type queryAccum struct {
overlap int
ids map[string]struct{}
mbids map[string]struct{}
}
func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[int][]scoredTrack {
queriesByArtist := make(map[string][]int)
for idx, ids := range r.byQuery {
for id := range ids {
queriesByArtist[id] = append(queriesByArtist[id], idx)
}
}
byQuery := make(map[int][]scoredTrack, len(r.byQuery))
for i := range tracks {
acc := map[int]*queryAccum{}
credited := map[string]struct{}{}
for _, p := range tracks[i].Participants[model.RoleArtist] {
if _, dup := credited[p.ID]; dup {
continue
}
owners, owned := queriesByArtist[p.ID]
if !owned {
continue
}
credited[p.ID] = struct{}{}
mbid := r.mbid[p.ID]
for _, idx := range owners {
a := acc[idx]
if a == nil {
a = &queryAccum{ids: map[string]struct{}{}, mbids: map[string]struct{}{}}
acc[idx] = a
}
a.overlap++
a.ids[p.ID] = struct{}{}
if mbid != "" {
a.mbids[mbid] = struct{}{}
}
}
}
for idx, a := range acc {
byQuery[idx] = append(byQuery[idx], scoredTrack{
sanitizedTrack: newSanitizedTrack(&tracks[i], a.ids, a.mbids),
overlap: a.overlap,
})
}
}
return byQuery
}
func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) (model.MediaFiles, error) {
if len(artistIDs) == 0 {
return nil, nil
}
args := slice.Map(artistIDs, func(id string) any { return id })
return m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.And{
squirrel.Expr(
"media_file.id IN (SELECT media_file_id FROM media_file_artists "+
"WHERE role = 'artist' AND artist_id IN ("+squirrel.Placeholders(len(artistIDs))+"))", args...),
squirrel.Eq{"missing": false},
},
Sort: "starred desc, rating desc, year asc, compilation asc",
})
}
func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 {
if durationMs == 0 {
return 1.0
}
durationSec := float64(durationMs) / 1000.0
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
return 1.0 / (1.0 + diff)
}
func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) {
var bestMatch model.MediaFile
bestScore := matchScore{titleSimilarity: -1}
found := false
preferStarred := conf.Server.Matcher.PreferStarred
for _, c := range candidates {
titleSim := similarityRatio(q.title, c.title)
if titleSim < threshold {
continue
}
var albumSim float64
if q.album != "" {
albumSim = similarityRatio(q.album, c.album)
}
score := matchScore{
titleSimilarity: titleSim,
durationProximity: durationProximity(q.durationMs, c.mf.Duration),
preferredMatch: preferStarred && isPreferredTrack(c.mf),
albumSimilarity: albumSim,
specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold),
artistOverlap: c.overlap,
}
if score.betterThan(bestScore) {
bestScore = score
bestMatch = *c.mf
found = true
}
}
return bestMatch, found
}
func isPreferredTrack(mf *model.MediaFile) bool {
return mf.Starred || mf.Rating >= 4
}
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]int, len(songs))
for i, s := range songs {
if len(mfs) == count {
break
}
mf, found := matches[i]
if !found {
continue
}
if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
if !s.Equals(songs[prevIdx]) {
continue
}
} else {
addedBy[mf.ID] = i
}
mfs = append(mfs, mf)
}
return mfs
}
func similarityRatio(a, b string) float64 {
if a == b {
return 1.0
}
if len(a) == 0 || len(b) == 0 {
return 0.0
}
return smetrics.JaroWinkler(a, b, 0.7, 4)
}