Blend real sonic distance into classic getSimilarSongs ranking
similarSongsFor still filters by genre (unchanged, and getSonicSimilarTracks remains the pure-sonic endpoint), but when the seed song has been analyzed, genre-matched candidates that also have stored audio_features are now ordered by actual feature-space distance from the seed instead of SQL's random order; candidates without analysis data are left at the end in their existing relative order rather than dropped or given a fabricated distance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
This commit is contained in:
@@ -4,10 +4,12 @@ import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/similarity"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||
)
|
||||
|
||||
@@ -182,18 +184,32 @@ func handleGetSimilarSongs2(rc *Context) *apiError {
|
||||
}
|
||||
|
||||
// similarSongsFor resolves id as a song, album, or artist (getSimilarSongs's
|
||||
// "id" can be any of the three in real Subsonic usage) and finds
|
||||
// genre-similar songs for it.
|
||||
// "id" can be any of the three in real Subsonic usage), finds
|
||||
// genre-similar songs for it, and — when the seed song has been sonic-
|
||||
// analyzed — re-ranks that genre-matched candidate set by actual sonic
|
||||
// distance rather than leaving it in the database's random order. This
|
||||
// never replaces the genre filter with pure sonic search (that's what
|
||||
// getSonicSimilarTracks is for); it just makes "similar" among
|
||||
// genre-mates mean something more concrete when the data exists to
|
||||
// back it up.
|
||||
func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) {
|
||||
if song, err := database.GetSong(db, id); err == nil {
|
||||
return database.SimilarSongsByGenre(db, song.ID, count)
|
||||
songs, err := database.SimilarSongsByGenre(db, song.ID, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reorderBySonicSimilarity(db, song.ID, songs), nil
|
||||
}
|
||||
if _, err := database.GetAlbum(db, id); err == nil {
|
||||
songs, err := database.ListSongsByAlbum(db, id)
|
||||
if err != nil || len(songs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return database.SimilarSongsByGenre(db, songs[0].ID, count)
|
||||
similar, err := database.SimilarSongsByGenre(db, songs[0].ID, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reorderBySonicSimilarity(db, songs[0].ID, similar), nil
|
||||
}
|
||||
if _, err := database.GetArtist(db, id); err == nil {
|
||||
return database.SimilarSongsByArtist(db, id, count)
|
||||
@@ -201,6 +217,50 @@ func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error)
|
||||
return nil, database.ErrNotFound
|
||||
}
|
||||
|
||||
// reorderBySonicSimilarity sorts songs by real sonic distance from
|
||||
// seedSongID when both the seed and (some of) the candidates have
|
||||
// stored audio_features; candidates without analysis data are left in
|
||||
// their existing relative order at the end, never dropped and never
|
||||
// assigned a fabricated distance.
|
||||
func reorderBySonicSimilarity(db *sql.DB, seedSongID string, songs []*database.Song) []*database.Song {
|
||||
if len(songs) < 2 {
|
||||
return songs
|
||||
}
|
||||
seed, err := database.GetAudioFeatures(db, seedSongID)
|
||||
if err != nil || seed == nil {
|
||||
return songs
|
||||
}
|
||||
|
||||
items := []similarity.Item{{ID: seedSongID, Vector: seed.Vector()}}
|
||||
analyzed := map[string]bool{}
|
||||
for _, s := range songs {
|
||||
f, err := database.GetAudioFeatures(db, s.ID)
|
||||
if err != nil || f == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, similarity.Item{ID: s.ID, Vector: f.Vector()})
|
||||
analyzed[s.ID] = true
|
||||
}
|
||||
if len(items) < 2 {
|
||||
return songs
|
||||
}
|
||||
idx := similarity.Build(items)
|
||||
|
||||
sorted := make([]*database.Song, len(songs))
|
||||
copy(sorted, songs)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
ai, aj := analyzed[sorted[i].ID], analyzed[sorted[j].ID]
|
||||
if ai != aj {
|
||||
return ai // analyzed songs sort before unanalyzed ones
|
||||
}
|
||||
if !ai {
|
||||
return false // preserve relative order among unanalyzed songs
|
||||
}
|
||||
return idx.DistanceBetween(seedSongID, sorted[i].ID) < idx.DistanceBetween(seedSongID, sorted[j].ID)
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func handleGetTopSongs(rc *Context) *apiError {
|
||||
count := rc.Params.Int("count", 50)
|
||||
db := rc.Server.DB
|
||||
|
||||
Reference in New Issue
Block a user