refactor: use duration as a soft ranking signal instead of hard cutoff in track matching (#4944)
* refactor: integrate duration into matchScore instead of using pre-filter Duration matching was handled as a binary pre-filter with fallback, inconsistent with how title, specificity, and album are scored via the matchScore system. Move duration into matchScore as a boolean field ranked between title similarity and specificity level, making all match criteria use the same hierarchical comparison. https://claude.ai/code/session_01BWJ5aAzbQRvwjB7PvUcNYs * refactor: remove findBestMatchInTracks function and integrate its logic into findBestMatch Signed-off-by: Deluan <deluan@navidrome.org> * refactor: use duration proximity score instead of boolean match Replace the binary durationMatch bool with a continuous durationProximity float64 (0.0-1.0) using 1/(1+diff). This removes the hard 3-second tolerance cutoff, so closer durations are always preferred over farther ones without an arbitrary cliff edge. https://claude.ai/code/session_01BWJ5aAzbQRvwjB7PvUcNYs * style: fix gofmt alignment in matchScore struct https://claude.ai/code/session_01BWJ5aAzbQRvwjB7PvUcNYs --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+22
-22
@@ -458,7 +458,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Duration filtering", func() {
|
||||
Describe("Duration matching", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -496,48 +496,48 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches within 3-second tolerance", func() {
|
||||
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 seconds (within tolerance)
|
||||
withinTolerance := model.MediaFile{
|
||||
ID: "within-tolerance", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||
// 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{withinTolerance})
|
||||
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("within-tolerance"))
|
||||
Expect(songs[0].ID).To(Equal("close-duration"))
|
||||
})
|
||||
|
||||
It("excludes tracks outside 3-second tolerance when other matches exist", func() {
|
||||
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 within tolerance, one outside
|
||||
withinTolerance := model.MediaFile{
|
||||
ID: "within", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
// Library has one close, one far
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
}
|
||||
outsideTolerance := model.MediaFile{
|
||||
ID: "outside", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
farDuration := model.MediaFile{
|
||||
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{outsideTolerance, withinTolerance})
|
||||
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("within"))
|
||||
Expect(songs[0].ID).To(Equal("close"))
|
||||
})
|
||||
|
||||
It("falls back to normal matching when no duration matches", func() {
|
||||
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},
|
||||
@@ -552,19 +552,19 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should fall back and return the track despite duration mismatch
|
||||
// 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("falls back to title match when duration-filtered tracks fail title threshold", func() {
|
||||
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 (should be found via fallback)
|
||||
// - 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,
|
||||
}
|
||||
@@ -577,7 +577,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should fall back to all tracks and find the title match
|
||||
// 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"))
|
||||
})
|
||||
@@ -605,8 +605,8 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
It("handles very short songs with duration tolerance", func() {
|
||||
// 30-second song with 1-second difference (within 3-second tolerance)
|
||||
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},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user