feat(plugins): add similar songs retrieval functions and improve duration consistency (#4933)

* feat: add duration filtering for similar songs matching

Signed-off-by: Deluan <deluan@navidrome.org>

* test: refactor expectations for similar songs in provider matching tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): add functions to retrieve similar songs by track, album, and artist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): support uint32 in ndpgen

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): update duration field to use seconds as float instead of milliseconds as uint32

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: add helper functions for Rust's skip_serializing_if with numeric types

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(provider): enhance track matching logic to fallback to title match when duration-filtered tracks fail

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-01-26 18:28:41 -05:00
committed by GitHub
parent 4d4740b83b
commit fda35dd8ce
20 changed files with 1147 additions and 70 deletions
+195 -28
View File
@@ -41,6 +41,26 @@ var _ = Describe("Provider - Song Matching", func() {
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
@@ -261,26 +281,6 @@ var _ = Describe("Provider - Song Matching", func() {
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
setupFuzzyExpectations := 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 now queries by artist in a single pass
// Note: loadTracksByID and loadTracksByMBID return early when no IDs/MBIDs
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("with default threshold (85%)", func() {
It("matches songs with remastered suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
@@ -294,7 +294,7 @@ var _ = Describe("Provider - Song Matching", func() {
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupFuzzyExpectations(returnedSongs, artistTracks)
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@@ -313,7 +313,7 @@ var _ = Describe("Provider - Song Matching", func() {
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
}
setupFuzzyExpectations(returnedSongs, artistTracks)
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@@ -334,7 +334,7 @@ var _ = Describe("Provider - Song Matching", func() {
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
}
setupFuzzyExpectations(returnedSongs, artistTracks)
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@@ -355,7 +355,7 @@ var _ = Describe("Provider - Song Matching", func() {
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupFuzzyExpectations(returnedSongs, artistTracks)
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@@ -375,7 +375,7 @@ var _ = Describe("Provider - Song Matching", func() {
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
}
setupFuzzyExpectations(returnedSongs, artistTracks)
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@@ -401,7 +401,7 @@ var _ = Describe("Provider - Song Matching", func() {
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
}
setupFuzzyExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@@ -424,7 +424,7 @@ var _ = Describe("Provider - Song Matching", func() {
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
}
setupFuzzyExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@@ -446,7 +446,7 @@ var _ = Describe("Provider - Song Matching", func() {
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
setupFuzzyExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@@ -457,4 +457,171 @@ var _ = Describe("Provider - Song Matching", func() {
})
})
})
Describe("Duration filtering", 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 within 3-second tolerance", 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 seconds (within tolerance)
withinTolerance := model.MediaFile{
ID: "within-tolerance", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{withinTolerance})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("within-tolerance"))
})
It("excludes tracks outside 3-second tolerance when other matches exist", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has one within tolerance, one outside
withinTolerance := model.MediaFile{
ID: "within", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
}
outsideTolerance := model.MediaFile{
ID: "outside", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{outsideTolerance, withinTolerance})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("within"))
})
It("falls back to normal matching when no duration matches", 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())
// Should fall back and return the track despite duration mismatch
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("different"))
})
It("falls back to title match when duration-filtered tracks fail title threshold", 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 (should be found via fallback)
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())
// Should fall back to all tracks and find the title match
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 duration tolerance", func() {
// 30-second song with 1-second difference (within 3-second tolerance)
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"))
})
})
})
})