package subsonic import ( "testing" "git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures" "git.noroutes.net/NoRoutes/vibetunes/internal/database" ) // makeAnalyzedSong creates a minimal song fixture (no real audio file // needed — sonic-similarity handlers only ever read the songs table and // audio_features, never touch disk) and stores a hand-crafted feature // vector for it, isolating these tests from ffmpeg/analysis timing. func makeAnalyzedSong(t *testing.T, s *Server, folderID, title string, tempo float64) *database.Song { t.Helper() mf, err := database.CreateMediaFile(s.DB, folderID, title+".mp3", 100, 0, "hash-"+title, "mp3", "song", 10, 128, 44100, 2) if err != nil { t.Fatal(err) } song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: title}) if err != nil { t.Fatal(err) } f := &audiofeatures.Features{TempoBPM: tempo, TempoValid: true, Key: "C major"} if err := database.UpsertAudioFeatures(s.DB, song.ID, f); err != nil { t.Fatal(err) } return song } func sonicTestLibrary(t *testing.T, s *Server) (slow, mid, fast, unanalyzed *database.Song) { t.Helper() folder, err := database.CreateMusicFolder(s.DB, "Music", "/music", "music") if err != nil { t.Fatal(err) } slow = makeAnalyzedSong(t, s, folder.ID, "Slow", 70) mid = makeAnalyzedSong(t, s, folder.ID, "Mid", 100) fast = makeAnalyzedSong(t, s, folder.ID, "Fast", 160) mf, err := database.CreateMediaFile(s.DB, folder.ID, "Unanalyzed.mp3", 100, 0, "hash-unanalyzed", "mp3", "song", 10, 128, 44100, 2) if err != nil { t.Fatal(err) } unanalyzed, err = database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Unanalyzed"}) if err != nil { t.Fatal(err) } return } func TestGetSonicSimilarTracksOrdersByRealDistance(t *testing.T) { s, _ := newTestServer(t) createTestUser(t, s, "alice", "password123", false) _, mid, fast, _ := sonicTestLibrary(t, s) srv := newSubsonicTestServer(t, s) q := baseParams("alice", "password123") q.Set("id", mid.ID) // 100bpm: 30bpm from Slow(70), 60bpm from Fast(160) — Slow should rank first q.Set("f", "json") resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q) j := decodeJSON(t, resp) matches, ok := j["sonicMatch"].([]any) if !ok || len(matches) != 2 { t.Fatalf("sonicMatch = %v, want 2 entries", j) } first := matches[0].(map[string]any) entry := first["entry"].(map[string]any) if entry["title"] != "Slow" { t.Errorf("nearest to Mid(100bpm) = %v, want Slow(70bpm) (closer than Fast(160bpm))", entry["title"]) } sim := first["similarity"].(float64) if sim <= 0 || sim > 1 { t.Errorf("similarity = %v, want in (0,1]", sim) } _ = fast } func TestGetSonicSimilarTracksExcludesUnanalyzedSongs(t *testing.T) { s, _ := newTestServer(t) createTestUser(t, s, "alice", "password123", false) slow, _, _, unanalyzed := sonicTestLibrary(t, s) srv := newSubsonicTestServer(t, s) q := baseParams("alice", "password123") q.Set("id", slow.ID) q.Set("f", "json") resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q) j := decodeJSON(t, resp) matches := j["sonicMatch"].([]any) for _, m := range matches { entry := m.(map[string]any)["entry"].(map[string]any) if entry["title"] == "Unanalyzed" { t.Error("a song with no stored audio_features must never appear in sonic-similarity results") } } _ = unanalyzed } func TestGetSonicSimilarTracksUnknownSong(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("id", "sg_doesnotexist") resp := getPath(t, srv.URL, "/rest/getSonicSimilarTracks.view", q) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound { t.Fatalf("expected error 70, got %+v", x) } } func TestGetSonicSimilarTracksNeverAnalyzedReturnsEmpty(t *testing.T) { s, _ := newTestServer(t) createTestUser(t, s, "alice", "password123", false) folder, err := database.CreateMusicFolder(s.DB, "Music", "/music", "music") if err != nil { t.Fatal(err) } mf, err := database.CreateMediaFile(s.DB, folder.ID, "solo.mp3", 100, 0, "h", "mp3", "song", 10, 128, 44100, 2) if err != nil { t.Fatal(err) } song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Solo"}) if err != nil { t.Fatal(err) } srv := newSubsonicTestServer(t, s) q := baseParams("alice", "password123") q.Set("id", song.ID) q.Set("f", "json") resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q) j := decodeJSON(t, resp) // No song in the library has been analyzed at all — VibeSonic must // return an honest empty result, never a fabricated match. if _, has := j["sonicMatch"]; has { t.Errorf("expected no sonicMatch key when nothing is analyzed, got %v", j["sonicMatch"]) } } func TestFindSonicPathReachesBothEndpointsAndOrdersMonotonically(t *testing.T) { s, _ := newTestServer(t) createTestUser(t, s, "alice", "password123", false) slow, _, fast, _ := sonicTestLibrary(t, s) srv := newSubsonicTestServer(t, s) q := baseParams("alice", "password123") q.Set("startSongId", slow.ID) q.Set("endSongId", fast.ID) q.Set("count", "3") q.Set("f", "json") resp := getPath(t, srv, "/rest/findSonicPath.view", q) j := decodeJSON(t, resp) matches, ok := j["sonicMatch"].([]any) if !ok || len(matches) != 3 { t.Fatalf("sonicMatch = %v, want 3 (start, mid, end)", j) } first := matches[0].(map[string]any)["entry"].(map[string]any) last := matches[2].(map[string]any)["entry"].(map[string]any) middle := matches[1].(map[string]any)["entry"].(map[string]any) if first["title"] != "Slow" || last["title"] != "Fast" { t.Fatalf("path endpoints = %v .. %v, want Slow .. Fast", first["title"], last["title"]) } if middle["title"] != "Mid" { t.Errorf("path middle hop = %v, want Mid (the only other analyzed song, between Slow and Fast)", middle["title"]) } startSim := matches[0].(map[string]any)["similarity"].(float64) if startSim != 1 { t.Errorf("similarity of start song to itself = %v, want 1", startSim) } } func TestFindSonicPathUnknownEndpoint(t *testing.T) { s, _ := newTestServer(t) createTestUser(t, s, "alice", "password123", false) slow, _, _, _ := sonicTestLibrary(t, s) srv := newSubsonicTestServer(t, s) q := baseParams("alice", "password123") q.Set("startSongId", slow.ID) q.Set("endSongId", "sg_doesnotexist") resp := getPath(t, srv, "/rest/findSonicPath.view", q) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound { t.Fatalf("expected error 70, got %+v", x) } }