mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 03:47:18 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
642 lines
20 KiB
Go
642 lines
20 KiB
Go
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"
|
|
)
|
|
|
|
// Matcher matches agent song results to local library tracks.
|
|
type Matcher struct {
|
|
ds model.DataStore
|
|
}
|
|
|
|
// New creates a new Matcher with the given DataStore.
|
|
func New(ds model.DataStore) *Matcher {
|
|
return &Matcher{ds: ds}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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
|
|
}
|
|
matches, err := m.resolveMatches(ctx, songs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return orderAndDedup(songs, matches, count), nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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))
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
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 // 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 nil
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
|
|
// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID
|
|
// resolution; name is pre-sanitized (article-stripped).
|
|
type queryArtist struct {
|
|
id string
|
|
name string
|
|
mbid string
|
|
}
|
|
|
|
// songQuery represents a normalized query for matching a song to library tracks.
|
|
type songQuery struct {
|
|
title string
|
|
artists []queryArtist
|
|
album string
|
|
albumMBID string
|
|
durationMs uint32
|
|
}
|
|
|
|
// matchScore combines title/album similarity with metadata specificity for ranking matches.
|
|
type matchScore struct {
|
|
titleSimilarity float64
|
|
durationProximity float64
|
|
preferredMatch bool
|
|
specificityLevel int
|
|
artistOverlap int
|
|
albumSimilarity float64
|
|
}
|
|
|
|
// betterThan returns true if this score beats another.
|
|
// Identity signals (specificity, overlap) outrank the taste signal (preferred).
|
|
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
|
|
}
|
|
|
|
// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization
|
|
// 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
|
|
artist string
|
|
album string
|
|
artistIDs map[string]struct{} // query's owned artist IDs this track credits; an ID match is the strongest identity signal
|
|
artistMBIDs map[string]struct{} // MBIDs of those artists (artist table; mf.MbzArtistID is not populated on the bulk path)
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best
|
|
// level achievable across any of the query's artists. Fields must be pre-sanitized.
|
|
//
|
|
// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the
|
|
// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers.
|
|
// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited
|
|
// to a different artist by the same name).
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
|
|
// groupQueries builds one normalized title query per still-unmatched song, carrying its full
|
|
// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name;
|
|
// songs with no usable artist are skipped (the title phase needs at least one to scope the query).
|
|
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
|
|
}
|
|
|
|
// resolvedArtists holds the agent artists resolved to artist-table rows, keyed by the query index
|
|
// that owns them. Routing is always by stable artist ID, never by name.
|
|
type resolvedArtists struct {
|
|
byQuery map[int]map[string]struct{} // query index -> set of resolved artist IDs
|
|
mbid map[string]string // artist ID -> its MBID (from the artist table)
|
|
allIDs []string // every resolved artist ID, for the track lookup
|
|
}
|
|
|
|
// resolveArtists resolves every artist of every query to artist-table rows. Artists that carry a
|
|
// Navidrome ID are owned directly (no name/MBID lookup). The remaining names/MBIDs are resolved in
|
|
// one batched query. Ownership is recorded per query index.
|
|
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{}{} // de-dupe across fast-path + resolved
|
|
|
|
// One pending entry per non-ID artist (carrying the query that owns it). ID-bearing artists
|
|
// take the fast-path and are owned directly.
|
|
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) // ID fast-path: own directly
|
|
allIDs[a.id] = struct{}{}
|
|
continue
|
|
}
|
|
pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index})
|
|
}
|
|
}
|
|
// query indices that supplied each order name / each MBID (skip the empty key — an artist may
|
|
// have only one of name/mbid).
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Query the artist table for name/MBID artists AND for the fast-path IDs (so their MBIDs are
|
|
// available for specificity scoring).
|
|
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{}{}
|
|
}
|
|
|
|
// scoredTrack is a candidate track for a query; overlap is how many of the query's distinct
|
|
// artist IDs the track credits.
|
|
type scoredTrack struct {
|
|
sanitizedTrack
|
|
overlap int
|
|
}
|
|
|
|
// queryAccum tallies, for one track against one query, the overlap count, the credited artists'
|
|
// owned IDs, and their MBIDs.
|
|
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] // "" if not in the artist-table result
|
|
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
|
|
}
|
|
|
|
// fetchTracksCreditedTo fetches every non-missing track credited to any of the given artists as
|
|
// the main artist (role='artist', not albumartist — that avoids tribute/compilation false
|
|
// positives). The non-correlated id IN (subquery) materializes the matching ids once from the
|
|
// media_file_artists(artist_id) covering index, far cheaper than a correlated EXISTS that re-runs
|
|
// per row. That form isn't expressible via the repository's role filters, so the raw squirrel.Expr
|
|
// keeps the media_file_artists schema knowledge here; a dedicated repository method would be the
|
|
// cleaner home if this is reused.
|
|
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",
|
|
})
|
|
}
|
|
|
|
// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration
|
|
// is to the target. Returns 1.0 if durationMs is 0 (unknown).
|
|
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)
|
|
}
|
|
|
|
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
|
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
|
|
}
|
|
|
|
// 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]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
|
|
}
|
|
|
|
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
|
|
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)
|
|
}
|