mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
refactor: extract song-to-library matcher to core/matcher package (#5348)
* refactor: extract matchSongsToLibrary to core/matcher package Move the song-to-library matching algorithm from core/external into its own core/matcher package. The Matcher struct exposes a single public method MatchSongsToLibrary that implements a multi-phase matching algorithm (ID > MBID > ISRC > fuzzy title+artist). Includes pre-sanitization optimization for the fuzzy matching loop. No behavioral changes — the algorithm is identical to the version in core/external/provider_matching.go. * refactor: inject matcher.Matcher via Wire instead of creating it inline Add *matcher.Matcher as a dependency of external.NewProvider, wired via Google Wire. Update all provider test files to pass matcher.New(ds). This eliminates tight coupling so future consumers can reuse the matcher without depending on the external package. * refactor: remove old provider_matching files Delete core/external/provider_matching.go and its tests. All matching logic now lives in core/matcher/. * test(matcher): restore test coverage lost in extraction Port back 23 specs that existed in the old provider_matching_test.go but were dropped during the extraction. Covers specificity levels, fuzzy matching thresholds, fuzzy album matching, duration matching, and deduplication edge cases. * test(matcher): extract matchFieldInAnd/matchFieldInEq helpers The four inline mock.MatchedBy closures in setupAllPhaseExpectations all followed the same squirrel.And -> squirrel.Eq -> field-name-check pattern. Extract into two small helpers to reduce duplication and make the setup functions read as a concise list of phase expectations. * refactor(matcher): address PR #5348 review feedback - sanitizedTrack now holds *model.MediaFile instead of a value copy. Since MediaFile is a large struct (~74 fields), this avoids the per-track copy into sanitized[] and a second copy when findBestMatch assigns the winner. loadTracksByTitleAndArtist updated to iterate by index and pass &tracks[i]. - loadTracksByISRC now sorts results (starred desc, rating desc, year asc, compilation asc) so that when multiple library tracks share an ISRC the most relevant one is picked deterministically, matching the sort order already used by loadTracksByTitleAndArtist. - Restored the four worked examples (MBID Priority, ISRC Priority, Specificity Ranking, Fuzzy Title Matching) in the MatchSongsToLibrary godoc that were dropped during the extraction. - matcher_test.go: tests now enforce expectations via AssertExpectations in a DeferCleanup. The old setupAllPhaseExpectations helper was replaced with per-phase helpers (expectIDPhase/expectMBIDPhase/expectISRCPhase + allowOtherPhases) so each test deterministically verifies which matching phases fire. This surfaced (and fixes) a latent issue copilot flagged: the old .Once() expectations were not actually asserted, so tests would silently pass even when phases short-circuited unexpectedly.
This commit is contained in:
parent
aa84e645ba
commit
52e47b896a
14 changed files with 947 additions and 899 deletions
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
|
|
@ -72,7 +73,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
|
|
@ -93,7 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
|
|
@ -121,7 +124,8 @@ func CreatePublicRouter() *public.Router {
|
|||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
|
|
@ -168,7 +172,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
|
|
@ -186,7 +191,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
|
|
|
|||
10
core/external/provider.go
vendored
10
core/external/provider.go
vendored
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
|
|
@ -41,6 +42,7 @@ type Provider interface {
|
|||
type provider struct {
|
||||
ds model.DataStore
|
||||
ag Agents
|
||||
matcher *matcher.Matcher
|
||||
artistQueue refreshQueue[auxArtist]
|
||||
albumQueue refreshQueue[auxAlbum]
|
||||
}
|
||||
|
|
@ -85,8 +87,8 @@ type Agents interface {
|
|||
agents.SimilarSongsByArtistRetriever
|
||||
}
|
||||
|
||||
func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
e := &provider{ds: ds, ag: agents}
|
||||
func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider {
|
||||
e := &provider{ds: ds, ag: agents, matcher: m}
|
||||
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
|
|
@ -300,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
|
|||
}
|
||||
|
||||
if err == nil && len(songs) > 0 {
|
||||
return e.matchSongsToLibrary(ctx, songs, count)
|
||||
return e.matcher.MatchSongsToLibrary(ctx, songs, count)
|
||||
}
|
||||
|
||||
// Fallback to existing similar artists + top songs algorithm
|
||||
|
|
@ -479,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
|||
}
|
||||
}
|
||||
|
||||
mfs, err := e.matchSongsToLibrary(ctx, songs, count)
|
||||
mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
3
core/external/provider_albumimage_test.go
vendored
3
core/external/provider_albumimage_test.go
vendored
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -43,7 +44,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
|||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||||
|
||||
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||||
|
||||
// Default mocks
|
||||
// Mocks for GetEntityByID sequence (initial failed lookups)
|
||||
|
|
|
|||
3
core/external/provider_artistimage_test.go
vendored
3
core/external/provider_artistimage_test.go
vendored
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -51,7 +52,7 @@ var _ = Describe("Provider - ArtistImage", func() {
|
|||
imageAgent: mockImageAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||||
|
||||
// Default mocks for successful Get calls
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()
|
||||
|
|
|
|||
762
core/external/provider_matching_test.go
vendored
762
core/external/provider_matching_test.go
vendored
|
|
@ -1,762 +0,0 @@
|
|||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - Song Matching", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var agentsCombined *mockAgents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var albumRepo *mockAlbumRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
albumRepo = newMockAlbumRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
}
|
||||
|
||||
agentsCombined = &mockAgents{}
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
// Shared helper for tests that only need artist track queries (no ID/MBID matching)
|
||||
setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) {
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(returnedSongs, nil).Once()
|
||||
|
||||
// loadTracksByTitleAndArtist - queries by artist name
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
})).Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Describe("matchSongsToLibrary priority matching", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) {
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(returnedSongs, nil).Once()
|
||||
|
||||
// loadTracksByID
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return ok
|
||||
})).Return(idMatches, nil).Once()
|
||||
|
||||
// loadTracksByMBID
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 1 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasMBID := eq["mbz_recording_id"]
|
||||
return hasMBID
|
||||
})).Return(mbidMatches, nil).Once()
|
||||
|
||||
// loadTracksByTitleAndArtist - now queries by artist name
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
})).Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Context("when agent returns artist and album metadata", func() {
|
||||
It("matches by title + artist MBID + album MBID (highest priority)", func() {
|
||||
// Song in library with all MBIDs
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
|
||||
}
|
||||
// Another song with same title but different MBIDs (should NOT match)
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
|
||||
}
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist name + album name when MBIDs unavailable", func() {
|
||||
// Song in library without MBIDs but with matching artist/album names
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
|
||||
}
|
||||
// Another song with same title but different artist (should NOT match)
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist only when album info unavailable", func() {
|
||||
// Song in library with matching artist
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
|
||||
}
|
||||
// Another song with same title but different artist
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode"}, // No album info
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("does not match songs without artist info", func() {
|
||||
// Songs without artist info cannot be matched since we query by artist
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song"}, // No artist/album info at all
|
||||
}
|
||||
|
||||
// No artist to query, so no GetAll calls for title matching
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when matching multiple songs with the same title but different artists", func() {
|
||||
It("returns distinct matches for each artist's version (covers scenario)", func() {
|
||||
// Multiple covers of the same song by different artists
|
||||
cover1 := model.MediaFile{
|
||||
ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
|
||||
}
|
||||
cover2 := model.MediaFile{
|
||||
ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits",
|
||||
}
|
||||
cover3 := model.MediaFile{
|
||||
ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
|
||||
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// All three covers should be returned, not just the first one
|
||||
Expect(songs).To(HaveLen(3))
|
||||
// Verify all three different versions are included
|
||||
ids := []string{songs[0].ID, songs[1].ID, songs[2].ID}
|
||||
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when matching multiple songs with different precision levels", func() {
|
||||
It("prefers more precise matches for each song", func() {
|
||||
// Library has multiple versions of same song
|
||||
preciseMatch := model.MediaFile{
|
||||
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
|
||||
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
|
||||
}
|
||||
lessAccurateMatch := model.MediaFile{
|
||||
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
|
||||
MbzArtistID: "mbid-1",
|
||||
}
|
||||
artistTwoMatch := model.MediaFile{
|
||||
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
|
||||
{Name: "Song B", Artist: "Artist Two"}, // Different artist
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
// First song should be the precise match (has all MBIDs)
|
||||
Expect(songs[0].ID).To(Equal("precise"))
|
||||
// Second song matches by title + artist
|
||||
Expect(songs[1].ID).To(Equal("artist-two"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Fuzzy matching fallback", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
Context("with default threshold (85%)", func() {
|
||||
It("matches songs with remastered suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
// Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered"
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
// Artist catalog has the remastered version (fuzzy match will find it)
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("remastered"))
|
||||
})
|
||||
|
||||
It("matches songs with live suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("live"))
|
||||
})
|
||||
|
||||
It("does not match completely different songs", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles"},
|
||||
}
|
||||
// Artist catalog has completely different songs
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
|
||||
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with threshold set to 100 (exact match only)", func() {
|
||||
It("only matches exact titles", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
// Artist catalog has only remastered version - no exact match
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with lower threshold (75%)", func() {
|
||||
It("matches more aggressively", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 75
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song", Artist: "Artist"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("extended"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with fuzzy album matching", func() {
|
||||
It("matches album with (Remaster) suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
// Agent returns "A Night at the Opera" but library has remastered version
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
// Library has same album with remaster suffix
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
// Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1)
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches album with (Deluxe Edition) suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("prefers exact album match over fuzzy album match", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
exactMatch := model.MediaFile{
|
||||
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||
}
|
||||
fuzzyMatch := model.MediaFile{
|
||||
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
// Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity)
|
||||
Expect(songs[0].ID).To(Equal("exact"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Duration matching", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests
|
||||
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
Context("when agent provides duration", func() {
|
||||
It("prefers tracks with matching duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has two versions: one matching duration, one not
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
wrongDuration := model.MediaFile{
|
||||
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches tracks with close duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has track with 182.5 seconds (close to target)
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("close-duration"))
|
||||
})
|
||||
|
||||
It("prefers closer duration over farther duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has one close, one far
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
}
|
||||
farDuration := model.MediaFile{
|
||||
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("close"))
|
||||
})
|
||||
|
||||
It("still matches when no tracks have matching duration", func() {
|
||||
// Agent returns song with duration 180000ms
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library only has tracks with very different duration
|
||||
differentDuration := model.MediaFile{
|
||||
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Duration mismatch doesn't exclude the track; it's just scored lower
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("different"))
|
||||
})
|
||||
|
||||
It("prefers title match over duration match when titles differ", func() {
|
||||
// Agent returns "Similar Song" with duration 180000ms
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has:
|
||||
// - differentTitle: matches duration but has different title (won't pass title threshold)
|
||||
// - correctTitle: doesn't match duration but has correct title (wins on title similarity)
|
||||
differentTitle := model.MediaFile{
|
||||
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
correctTitle := model.MediaFile{
|
||||
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Title similarity is the top priority, so the correct title wins despite duration mismatch
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-title"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when agent does not provide duration", func() {
|
||||
It("matches without duration filtering (duration=0)", func() {
|
||||
// Agent returns song without duration
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
|
||||
}
|
||||
// Library tracks with various durations should all be candidates
|
||||
anyTrack := model.MediaFile{
|
||||
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("any"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
It("handles very short songs with close duration", func() {
|
||||
// 30-second song with 1-second difference
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
|
||||
}
|
||||
shortTrack := model.MediaFile{
|
||||
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("short"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Deduplication of mismatched songs", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching
|
||||
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
It("removes duplicates when different input songs match the same library track", func() {
|
||||
// Agent returns two different versions that will both fuzzy-match to the same library track
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
|
||||
}
|
||||
// Library only has one version
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should only return one track, not two duplicates
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("br-live"))
|
||||
})
|
||||
|
||||
It("preserves duplicates when identical input songs match the same library track", func() {
|
||||
// Agent returns the exact same song twice (intentional repetition)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
// Library has matching track
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return two tracks since input songs were identical
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("br"))
|
||||
Expect(songs[1].ID).To(Equal("br"))
|
||||
})
|
||||
|
||||
It("handles mixed scenario with both identical and different input songs", func() {
|
||||
// Agent returns: Song A, Song B (different from A), Song A again (same as first)
|
||||
// All three match to the same library track
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first
|
||||
{Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version
|
||||
}
|
||||
// Library only has one version
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return 2 tracks:
|
||||
// 1. First "Yesterday" (original)
|
||||
// 2. Third "Yesterday" (same as first, so kept)
|
||||
// Skip: Second "Yesterday (Remastered)" (different input, same library track)
|
||||
// Skip: Fourth "Yesterday (Anthology)" (different input, same library track)
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("yesterday"))
|
||||
Expect(songs[1].ID).To(Equal("yesterday"))
|
||||
})
|
||||
|
||||
It("does not deduplicate songs that match different library tracks", func() {
|
||||
// Agent returns different songs that match different library tracks
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song C", Artist: "Artist"},
|
||||
}
|
||||
// Library has all three songs
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
|
||||
trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// All three should be returned since they match different library tracks
|
||||
Expect(songs).To(HaveLen(3))
|
||||
Expect(songs[0].ID).To(Equal("track-a"))
|
||||
Expect(songs[1].ID).To(Equal("track-b"))
|
||||
Expect(songs[2].ID).To(Equal("track-c"))
|
||||
})
|
||||
|
||||
It("respects count limit after deduplication", func() {
|
||||
// Agent returns 4 songs: 2 unique + 2 that would create duplicates
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track
|
||||
}
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB})
|
||||
|
||||
// Request only 2 songs
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return exactly 2: Song A and Song B (skipping duplicates)
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("track-a"))
|
||||
Expect(songs[1].ID).To(Equal("track-b"))
|
||||
})
|
||||
})
|
||||
})
|
||||
3
core/external/provider_similarsongs_test.go
vendored
3
core/external/provider_similarsongs_test.go
vendored
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -48,7 +49,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||||
})
|
||||
|
||||
Describe("dispatch by entity type", func() {
|
||||
|
|
|
|||
3
core/external/provider_topsongs_test.go
vendored
3
core/external/provider_topsongs_test.go
vendored
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -44,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() {
|
|||
|
||||
ag = new(mockAgents)
|
||||
|
||||
p = NewProvider(ds, ag)
|
||||
p = NewProvider(ds, ag, matcher.New(ds))
|
||||
})
|
||||
|
||||
It("returns top songs for a known artist", func() {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -34,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
|||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
p = external.NewProvider(ds, ag, matcher.New(ds))
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -37,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
|||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
p = external.NewProvider(ds, ag, matcher.New(ds))
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package external
|
||||
package matcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -13,7 +13,17 @@ import (
|
|||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
|
||||
// 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}
|
||||
}
|
||||
|
||||
// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase
|
||||
// matching algorithm that prioritizes accuracy over recall.
|
||||
//
|
||||
// # Algorithm Overview
|
||||
|
|
@ -95,36 +105,34 @@ import (
|
|||
//
|
||||
// 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.
|
||||
func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
idMatches, err := e.loadTracksByID(ctx, songs)
|
||||
func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
idMatches, err := m.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches)
|
||||
mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
|
||||
isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
|
||||
titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
|
||||
return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
|
||||
return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
|
||||
}
|
||||
|
||||
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
|
||||
// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys.
|
||||
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.
|
||||
// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match.
|
||||
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 {
|
||||
|
|
@ -140,10 +148,7 @@ func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (mod
|
|||
}
|
||||
|
||||
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
|
||||
// It extracts all non-empty ID fields from the input songs and performs a single
|
||||
// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup.
|
||||
// Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
|
|
@ -154,7 +159,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map
|
|||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
squirrel.Eq{"missing": false},
|
||||
|
|
@ -172,10 +177,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map
|
|||
}
|
||||
|
||||
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
|
||||
// It extracts all non-empty MBID fields from the input songs and performs a single
|
||||
// batch query against the mbz_recording_id column. Returns a map keyed by MBID for
|
||||
// O(1) lookup. Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
|
||||
|
|
@ -186,7 +188,7 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr
|
|||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"mbz_recording_id": mbids},
|
||||
squirrel.Eq{"missing": false},
|
||||
|
|
@ -205,11 +207,8 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr
|
|||
return matches, nil
|
||||
}
|
||||
|
||||
// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard
|
||||
// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and
|
||||
// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for
|
||||
// O(1) lookup. Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
// 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) {
|
||||
var isrcs []string
|
||||
for _, s := range songs {
|
||||
if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
|
||||
|
|
@ -220,8 +219,9 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr
|
|||
if len(isrcs) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
|
||||
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
|
||||
|
|
@ -237,27 +237,24 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr
|
|||
}
|
||||
|
||||
// songQuery represents a normalized query for matching a song to library tracks.
|
||||
// All string fields are sanitized (lowercased, diacritics removed) for comparison.
|
||||
// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist.
|
||||
type songQuery struct {
|
||||
title string // Sanitized song title
|
||||
artist string // Sanitized artist name (without articles like "The")
|
||||
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
|
||||
album string // Sanitized album name (optional, for specificity scoring)
|
||||
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
|
||||
durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering)
|
||||
title string
|
||||
artist string
|
||||
artistMBID string
|
||||
album string
|
||||
albumMBID string
|
||||
durationMs uint32
|
||||
}
|
||||
|
||||
// matchScore combines title/album similarity with metadata specificity for ranking matches
|
||||
// matchScore combines title/album similarity with metadata specificity for ranking matches.
|
||||
type matchScore struct {
|
||||
titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler)
|
||||
durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown)
|
||||
albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker
|
||||
specificityLevel int // 0-5 (higher = more specific metadata match)
|
||||
titleSimilarity float64
|
||||
durationProximity float64
|
||||
albumSimilarity float64
|
||||
specificityLevel int
|
||||
}
|
||||
|
||||
// betterThan returns true if this score beats another.
|
||||
// Comparison order: title similarity > duration proximity > specificity level > album similarity
|
||||
func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.titleSimilarity != other.titleSimilarity {
|
||||
return s.titleSimilarity > other.titleSimilarity
|
||||
|
|
@ -271,58 +268,62 @@ func (s matchScore) betterThan(other matchScore) bool {
|
|||
return s.albumSimilarity > other.albumSimilarity
|
||||
}
|
||||
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
|
||||
// Higher values indicate more specific matches (MBIDs > names > title only).
|
||||
// Uses fuzzy matching for album names with the same threshold as title matching.
|
||||
func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int {
|
||||
title := str.SanitizeFieldForSorting(mf.Title)
|
||||
artist := str.SanitizeFieldForSortingNoArticle(mf.Artist)
|
||||
album := str.SanitizeFieldForSorting(mf.Album)
|
||||
// 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.
|
||||
type sanitizedTrack struct {
|
||||
mf *model.MediaFile
|
||||
title string
|
||||
artist string
|
||||
album string
|
||||
}
|
||||
|
||||
// Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack {
|
||||
return sanitizedTrack{
|
||||
mf: mf,
|
||||
title: str.SanitizeFieldForSorting(mf.Title),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
|
||||
album: str.SanitizeFieldForSorting(mf.Album),
|
||||
}
|
||||
}
|
||||
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
|
||||
// The track's title, artist, and album fields must be pre-sanitized.
|
||||
func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
|
||||
if q.artistMBID != "" && q.albumMBID != "" &&
|
||||
mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID {
|
||||
t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID {
|
||||
return 5
|
||||
}
|
||||
// Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
if q.artistMBID != "" && q.album != "" &&
|
||||
mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold {
|
||||
t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold {
|
||||
return 4
|
||||
}
|
||||
// Level 3: Title + Artist name + Album name (fuzzy)
|
||||
if q.artist != "" && q.album != "" &&
|
||||
artist == q.artist && similarityRatio(album, q.album) >= albumThreshold {
|
||||
t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold {
|
||||
return 3
|
||||
}
|
||||
// Level 2: Title + Artist MBID
|
||||
if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID {
|
||||
if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID {
|
||||
return 2
|
||||
}
|
||||
// Level 1: Title + Artist name
|
||||
if q.artist != "" && artist == q.artist {
|
||||
if q.artist != "" && t.artist == q.artist {
|
||||
return 1
|
||||
}
|
||||
// Level 0: Title only match (but for fuzzy, title matched via similarity)
|
||||
// Check if at least the title matches exactly
|
||||
if title == q.title {
|
||||
if t.title == q.title {
|
||||
return 0
|
||||
}
|
||||
return -1 // No exact title match, but could still be a fuzzy match
|
||||
return -1
|
||||
}
|
||||
|
||||
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
|
||||
// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with
|
||||
// metadata specificity (MBIDs, album names) for both exact and fuzzy matches.
|
||||
// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs.
|
||||
func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
queries := e.buildTitleQueries(songs, priorMatches...)
|
||||
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
|
||||
}
|
||||
|
||||
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
|
||||
|
||||
// Group queries by artist for efficient DB access
|
||||
byArtist := map[string][]songQuery{}
|
||||
for _, q := range queries {
|
||||
if q.artist != "" {
|
||||
|
|
@ -332,8 +333,7 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
|||
|
||||
matches := map[string]model.MediaFile{}
|
||||
for artist, artistQueries := range byArtist {
|
||||
// Single DB query per artist - get all their tracks
|
||||
tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"order_artist_name": artist},
|
||||
squirrel.Eq{"missing": false},
|
||||
|
|
@ -344,9 +344,13 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
|||
continue
|
||||
}
|
||||
|
||||
// Find best match for each query using unified scoring
|
||||
sanitized := make([]sanitizedTrack, len(tracks))
|
||||
for i := range tracks {
|
||||
sanitized[i] = newSanitizedTrack(&tracks[i])
|
||||
}
|
||||
|
||||
for _, q := range artistQueries {
|
||||
if mf, found := e.findBestMatch(q, tracks, threshold); found {
|
||||
if mf, found := m.findBestMatch(q, sanitized, threshold); found {
|
||||
key := q.title + "|" + q.artist
|
||||
if _, exists := matches[key]; !exists {
|
||||
matches[key] = mf
|
||||
|
|
@ -357,13 +361,11 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
|||
return matches, nil
|
||||
}
|
||||
|
||||
// durationProximity returns a score from 0.0 to 1.0 indicating how close
|
||||
// the track's duration is to the target. A perfect match returns 1.0, and the
|
||||
// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0
|
||||
// if durationMs is 0 (unknown), so duration does not influence scoring.
|
||||
// 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 // Unknown duration — don't penalise
|
||||
if durationMs == 0 {
|
||||
return 1.0
|
||||
}
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
|
||||
|
|
@ -371,41 +373,33 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64
|
|||
}
|
||||
|
||||
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
||||
// A track must meet the threshold for title similarity, then the best match is chosen by:
|
||||
// 1. Highest title similarity
|
||||
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
||||
// 3. Highest specificity level
|
||||
// 4. Highest album similarity (as final tiebreaker)
|
||||
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
|
||||
func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) {
|
||||
var bestMatch model.MediaFile
|
||||
bestScore := matchScore{titleSimilarity: -1}
|
||||
found := false
|
||||
|
||||
for _, mf := range tracks {
|
||||
trackTitle := str.SanitizeFieldForSorting(mf.Title)
|
||||
titleSim := similarityRatio(q.title, trackTitle)
|
||||
for _, t := range sanitizedTracks {
|
||||
titleSim := similarityRatio(q.title, t.title)
|
||||
|
||||
if titleSim < threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute album similarity for tiebreaking (0.0 if no album in query)
|
||||
var albumSim float64
|
||||
if q.album != "" {
|
||||
trackAlbum := str.SanitizeFieldForSorting(mf.Album)
|
||||
albumSim = similarityRatio(q.album, trackAlbum)
|
||||
albumSim = similarityRatio(q.album, t.album)
|
||||
}
|
||||
|
||||
score := matchScore{
|
||||
titleSimilarity: titleSim,
|
||||
durationProximity: durationProximity(q.durationMs, mf.Duration),
|
||||
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, mf, threshold),
|
||||
specificityLevel: computeSpecificityLevel(q, t, threshold),
|
||||
}
|
||||
|
||||
if score.betterThan(bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = mf
|
||||
bestMatch = *t.mf
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
|
@ -413,9 +407,7 @@ func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold
|
|||
}
|
||||
|
||||
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
|
||||
// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes
|
||||
// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names).
|
||||
func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||
var queries []songQuery
|
||||
for _, s := range songs {
|
||||
if songMatchedIn(s, priorMatches...) {
|
||||
|
|
@ -434,18 +426,9 @@ func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[st
|
|||
}
|
||||
|
||||
// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
|
||||
// library tracks. It iterates through the input songs in order and selects the first available match
|
||||
// using priority order: ID > MBID > ISRC > title+artist.
|
||||
//
|
||||
// The function also handles deduplication: when multiple different input songs would match the same
|
||||
// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library),
|
||||
// only the first match is kept. However, if the same input song appears multiple times (intentional
|
||||
// repetition), duplicates are preserved in the output.
|
||||
//
|
||||
// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped.
|
||||
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
// 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 {
|
||||
mfs := make(model.MediaFiles, 0, len(songs))
|
||||
// Track MediaFile.ID -> input song that added it, for deduplication
|
||||
addedBy := make(map[string]agents.Song, len(songs))
|
||||
|
||||
for _, t := range songs {
|
||||
|
|
@ -458,11 +441,9 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by
|
|||
continue
|
||||
}
|
||||
|
||||
// Check for duplicate library track
|
||||
if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
|
||||
// Only add duplicate if input songs are identical
|
||||
if t != prevSong {
|
||||
continue // Different input songs → skip mismatch-induced duplicate
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
addedBy[mf.ID] = t
|
||||
|
|
@ -473,14 +454,11 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by
|
|||
return mfs
|
||||
}
|
||||
|
||||
// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist.
|
||||
// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists.
|
||||
// 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) {
|
||||
// Try identifier-based matches first (ID, MBID, ISRC)
|
||||
if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
|
||||
return mf, true
|
||||
}
|
||||
// Fall back to title+artist fuzzy match
|
||||
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
|
||||
if mf, ok := byTitleArtist[key]; ok {
|
||||
return mf, true
|
||||
|
|
@ -489,9 +467,6 @@ func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[st
|
|||
}
|
||||
|
||||
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
|
||||
// Returns a value between 0.0 (completely different) and 1.0 (identical).
|
||||
// Jaro-Winkler is well-suited for matching song titles because it gives higher scores
|
||||
// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered").
|
||||
func similarityRatio(a, b string) float64 {
|
||||
if a == b {
|
||||
return 1.0
|
||||
|
|
@ -499,6 +474,5 @@ func similarityRatio(a, b string) float64 {
|
|||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
// JaroWinkler params: boostThreshold=0.7, prefixSize=4
|
||||
return smetrics.JaroWinkler(a, b, 0.7, 4)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package external
|
||||
package matcher
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -16,25 +16,21 @@ var _ = Describe("similarityRatio", func() {
|
|||
})
|
||||
|
||||
It("returns high similarity for remastered suffix", func() {
|
||||
// Jaro-Winkler gives ~0.92 for this case
|
||||
ratio := similarityRatio("paranoid android", "paranoid android remastered")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns high similarity for suffix additions like (Live)", func() {
|
||||
// Jaro-Winkler gives ~0.96 for this case
|
||||
ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.90))
|
||||
})
|
||||
|
||||
It("returns high similarity for 'yesterday' variants (common prefix)", func() {
|
||||
// Jaro-Winkler gives ~0.90 because of common prefix
|
||||
ratio := similarityRatio("yesterday", "yesterday once more")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns low similarity for same suffix", func() {
|
||||
// Jaro-Winkler gives ~0.70 for this case
|
||||
ratio := similarityRatio("postman (live)", "taxman (live)")
|
||||
Expect(ratio).To(BeNumerically("<", 0.85))
|
||||
})
|
||||
17
core/matcher/matcher_suite_test.go
Normal file
17
core/matcher/matcher_suite_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package matcher_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMatcher(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Matcher Suite")
|
||||
}
|
||||
807
core/matcher/matcher_test.go
Normal file
807
core/matcher/matcher_test.go
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
package matcher_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Matcher", func() {
|
||||
var ds model.DataStore
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var ctx context.Context
|
||||
var m *matcher.Matcher
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
DeferCleanup(func() {
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
m = matcher.New(ds)
|
||||
})
|
||||
|
||||
// Per-phase expectation helpers. Each `expect*Phase` registers a .Once() expectation
|
||||
// that will fail the suite via AssertExpectations if the phase is NOT called. Tests
|
||||
// use these to deterministically verify which matching phases fire. Phases that may
|
||||
// or may not fire should use the `allow*Phase` variants instead, which register
|
||||
// .Maybe() fallbacks.
|
||||
expectIDPhase := func(matches model.MediaFiles) {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))).
|
||||
Return(matches, nil).Once()
|
||||
}
|
||||
expectMBIDPhase := func(matches model.MediaFiles) {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))).
|
||||
Return(matches, nil).Once()
|
||||
}
|
||||
expectISRCPhase := func(matches model.MediaFiles) {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))).
|
||||
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() {
|
||||
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()
|
||||
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) {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))).
|
||||
Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Describe("MatchSongsToLibrary", func() {
|
||||
Context("matching by direct ID", func() {
|
||||
It("matches songs with an ID field to MediaFiles by ID", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{ID: "track-1", Name: "Some Song", Artist: "Some Artist"},
|
||||
}
|
||||
idMatch := model.MediaFile{
|
||||
ID: "track-1", Title: "Some Song", Artist: "Some Artist",
|
||||
}
|
||||
expectIDPhase(model.MediaFiles{idMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("matching by MBID", func() {
|
||||
It("matches songs with MBID to tracks with matching mbz_recording_id", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"},
|
||||
}
|
||||
mbidMatch := model.MediaFile{
|
||||
ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead",
|
||||
MbzRecordingID: "abc-123",
|
||||
}
|
||||
expectMBIDPhase(model.MediaFiles{mbidMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-mbid"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("matching by ISRC", func() {
|
||||
It("matches songs with ISRC to tracks with matching ISRC tag", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"},
|
||||
}
|
||||
isrcMatch := model.MediaFile{
|
||||
ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead",
|
||||
Tags: model.Tags{model.TagISRC: []string{"GBAYE0000351"}},
|
||||
}
|
||||
expectISRCPhase(model.MediaFiles{isrcMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-isrc"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("fuzzy title+artist matching", func() {
|
||||
It("matches songs by title and artist name", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode"},
|
||||
}
|
||||
titleMatch := model.MediaFile{
|
||||
ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{titleMatch})
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-title"))
|
||||
})
|
||||
|
||||
It("matches songs with fuzzy title similarity", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
}
|
||||
fuzzyMatch := model.MediaFile{
|
||||
ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch})
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-fuzzy"))
|
||||
})
|
||||
|
||||
It("does not match completely different titles", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles"},
|
||||
}
|
||||
differentTracks := model.MediaFiles{
|
||||
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
|
||||
}
|
||||
setupTitleOnlyExpectations(differentTracks)
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("deduplication", func() {
|
||||
It("removes duplicates when different input songs match the same library track", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
|
||||
}
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("br-live"))
|
||||
})
|
||||
|
||||
It("preserves duplicates when identical input songs match the same library track", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("br"))
|
||||
Expect(result[1].ID).To(Equal("br"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("priority ordering", func() {
|
||||
It("prefers ID match over MBID match", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
// Song has both ID and MBID set. The matcher should resolve via ID
|
||||
// and short-circuit the MBID phase entirely, so no MBID fetch should
|
||||
// occur even though an mbz_recording_id exists in the input.
|
||||
songs := []agents.Song{
|
||||
{ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"},
|
||||
}
|
||||
idMatch := model.MediaFile{
|
||||
ID: "track-id", Title: "Song", Artist: "Artist",
|
||||
}
|
||||
expectIDPhase(model.MediaFiles{idMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-id"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("count limit", func() {
|
||||
It("returns at most 'count' results", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song C", Artist: "Artist"},
|
||||
}
|
||||
tracks := model.MediaFiles{
|
||||
{ID: "a", Title: "Song A", Artist: "Artist"},
|
||||
{ID: "b", Title: "Song B", Artist: "Artist"},
|
||||
{ID: "c", Title: "Song C", Artist: "Artist"},
|
||||
}
|
||||
setupTitleOnlyExpectations(tracks)
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("empty input", func() {
|
||||
It("returns empty results for no songs", func() {
|
||||
result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("specificity level matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
})
|
||||
|
||||
It("matches by title + artist MBID + album MBID (highest priority)", func() {
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
|
||||
}
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist name + album name when MBIDs unavailable", func() {
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist only when album info unavailable", func() {
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("does not match songs without artist info", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns distinct matches for each artist's version (covers scenario)", func() {
|
||||
cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"}
|
||||
cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}
|
||||
cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
|
||||
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(3))
|
||||
ids := []string{result[0].ID, result[1].ID, result[2].ID}
|
||||
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
|
||||
})
|
||||
|
||||
It("prefers more precise matches for each song", func() {
|
||||
preciseMatch := model.MediaFile{
|
||||
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
|
||||
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
|
||||
}
|
||||
lessAccurateMatch := model.MediaFile{
|
||||
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
|
||||
MbzArtistID: "mbid-1",
|
||||
}
|
||||
artistTwoMatch := model.MediaFile{
|
||||
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
|
||||
}
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
|
||||
{Name: "Song B", Artist: "Artist Two"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("precise"))
|
||||
Expect(result[1].ID).To(Equal("artist-two"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fuzzy matching thresholds", func() {
|
||||
Context("with default threshold (85%)", func() {
|
||||
It("matches songs with remastered suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("remastered"))
|
||||
})
|
||||
|
||||
It("matches songs with live suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("live"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with threshold set to 100 (exact match only)", func() {
|
||||
It("only matches exact titles", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with lower threshold (75%)", func() {
|
||||
It("matches more aggressively", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 75
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Song", Artist: "Artist"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("extended"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fuzzy album matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
})
|
||||
|
||||
It("matches album with (Remaster) suffix", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches album with (Deluxe Edition) suffix", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("prefers exact album match over fuzzy album match", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
exactMatch := model.MediaFile{
|
||||
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||
}
|
||||
fuzzyMatch := model.MediaFile{
|
||||
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("exact"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("duration matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
})
|
||||
|
||||
It("prefers tracks with matching duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
wrongDuration := model.MediaFile{
|
||||
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches tracks with close duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{closeDuration})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("close-duration"))
|
||||
})
|
||||
|
||||
It("prefers closer duration over farther duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
}
|
||||
farDuration := model.MediaFile{
|
||||
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("close"))
|
||||
})
|
||||
|
||||
It("still matches when no tracks have matching duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
differentDuration := model.MediaFile{
|
||||
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{differentDuration})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("different"))
|
||||
})
|
||||
|
||||
It("prefers title match over duration match when titles differ", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
differentTitle := model.MediaFile{
|
||||
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
correctTitle := model.MediaFile{
|
||||
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct-title"))
|
||||
})
|
||||
|
||||
It("matches without duration filtering when agent duration is 0", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
|
||||
}
|
||||
anyTrack := model.MediaFile{
|
||||
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{anyTrack})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("any"))
|
||||
})
|
||||
|
||||
It("handles very short songs with close duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
|
||||
}
|
||||
shortTrack := model.MediaFile{
|
||||
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{shortTrack})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("short"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("deduplication edge cases", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
})
|
||||
|
||||
It("handles mixed scenario with both identical and different input songs", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"},
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"},
|
||||
}
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("yesterday"))
|
||||
Expect(result[1].ID).To(Equal("yesterday"))
|
||||
})
|
||||
|
||||
It("does not deduplicate songs that match different library tracks", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song C", Artist: "Artist"},
|
||||
}
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
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})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(3))
|
||||
Expect(result[0].ID).To(Equal("track-a"))
|
||||
Expect(result[1].ID).To(Equal("track-b"))
|
||||
Expect(result[2].ID).To(Equal("track-c"))
|
||||
})
|
||||
|
||||
It("respects count limit after deduplication", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song A (Live)", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song B (Remix)", Artist: "Artist"},
|
||||
}
|
||||
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})
|
||||
|
||||
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("track-a"))
|
||||
Expect(result[1].ID).To(Equal("track-b"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockMediaFileRepo struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMockMediaFileRepo() *mockMediaFileRepo {
|
||||
return &mockMediaFileRepo{}
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(options...)
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a
|
||||
// squirrel.And whose first element is a squirrel.Eq containing the given field name.
|
||||
func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool {
|
||||
return func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasField := eq[fieldName]
|
||||
return hasField
|
||||
}
|
||||
}
|
||||
|
||||
// matchFieldInEq returns a matcher that checks whether QueryOptions.Filters is a
|
||||
// squirrel.Eq containing the given field name.
|
||||
func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool {
|
||||
return func(opt model.QueryOptions) bool {
|
||||
eq, ok := opt.Filters.(squirrel.Eq)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, hasField := eq[fieldName]
|
||||
return hasField
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
|
|
@ -28,6 +29,7 @@ var Set = wire.NewSet(
|
|||
stream.NewTranscodeDecider,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
matcher.New,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
ffmpeg.New,
|
||||
scrobbler.GetPlayTracker,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue