Files
vibesonic/internal/subsonic/opensubsonic_core_test.go
T
owenandClaude Sonnet 5 b6acd63c2d Implement sonicSimilarity extension with real audio feature analysis
Adds internal/audiofeatures: decodes each scanned song to PCM via
ffmpeg and computes actual measured characteristics — tempo (via
autocorrelation of the frame energy envelope, left unset rather than
guessed when a clip is too short to contain a beat), an approximate
loudness figure, spectral centroid/rolloff and zero-crossing rate (via
a hand-written FFT), and a 12-bin chroma vector used both for a
Krumhansl-Kessler key estimate and as similarity-index input. No
external DSP/ML dependency.

The scanner now runs this analysis after indexing each song (as part
of the already-background scan, not on any request path) and stores
results in the existing audio_features table via the new
database.UpsertAudioFeatures/GetAudioFeatures/ListAllAudioFeatures.

internal/similarity is a small in-process nearest-neighbor index:
z-score normalizes each feature dimension across the analyzed library
and answers Nearest (ranked real distances) and Path (a feature-space
straight-line walk from one track to another, hopping to the closest
not-yet-used real song at each step — never a fabricated intermediate
track).

getSonicSimilarTracks and findSonicPath expose these through the
standard sonicMatch response, converting distance to a normalized
(0,1] similarity score. A song that hasn't been analyzed yet is simply
absent from results rather than faked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:30:20 -05:00

275 lines
8.7 KiB
Go

package subsonic
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func TestResponseEnvelopeHasOpenSubsonicFields(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "json")
resp := get(t, srv.URL, q)
j := decodeJSON(t, resp)
if j["openSubsonic"] != true {
t.Errorf("openSubsonic = %v, want true", j["openSubsonic"])
}
if j["type"] != ServerName {
t.Errorf("type = %v, want %q", j["type"], ServerName)
}
if j["serverVersion"] == nil || j["serverVersion"] == "" {
t.Errorf("serverVersion missing: %v", j)
}
}
func TestGetOpenSubsonicExtensions(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getOpenSubsonicExtensions.view", q)
j := decodeJSON(t, resp)
exts, ok := j["openSubsonicExtensions"].([]any)
if !ok || len(exts) == 0 {
t.Fatalf("openSubsonicExtensions missing/empty: %v", j)
}
names := map[string]bool{}
for _, e := range exts {
m := e.(map[string]any)
names[m["name"].(string)] = true
if versions, ok := m["versions"].([]any); !ok || len(versions) == 0 {
t.Errorf("extension %v has no versions", m["name"])
}
}
// This list grows as each extension is implemented through this
// phase; see docs/subsonic-compatibility.md for the final complete
// set once Phase 3 is done.
for _, want := range []string{
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
"indexBasedQueue", "playbackReport", "songLyrics", "transcoding", "sonicSimilarity",
} {
if !names[want] {
t.Errorf("extension %q not advertised: %v", want, names)
}
}
}
func TestGetOpenSubsonicExtensionsNeverStubbed(t *testing.T) {
// Regression guard for "no fake support": every advertised name must
// correspond to a real registered handler or a documented framework
// behavior (formPost/apiKeyAuthentication/transcodeOffset don't have
// their own endpoint, everything else does).
needsHandler := map[string]string{
"getPodcastEpisode": "getPodcastEpisode",
"indexBasedQueue": "getPlayQueueByIndex",
"playbackReport": "reportPlayback",
"songLyrics": "getLyricsBySongId",
"sonicSimilarity": "getSonicSimilarTracks",
"topSongsByArtistId": "getTopSongs",
"transcoding": "getTranscodeDecision",
}
for ext, endpoint := range needsHandler {
if !hasExtension(ext) {
continue
}
if _, ok := registry[endpoint]; !ok {
t.Errorf("extension %q advertised but handler %q not registered", ext, endpoint)
}
}
}
func TestAPIKeyAuthenticationSuccess(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
rawKey, err := vsauth.GenerateAPIKey()
if err != nil {
t.Fatal(err)
}
if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "TestClient/1.0"); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Router())
defer srv.Close()
q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}, "f": {"json"}}
resp := get(t, srv.URL, q)
j := decodeJSON(t, resp)
if j["status"] != "ok" {
t.Fatalf("apiKey auth failed: %v", j)
}
}
func TestAPIKeyAuthenticationInvalidKey(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := url.Values{"apiKey": {"vs_bogus"}, "v": {"1.16.1"}, "c": {"test"}}
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrInvalidAPIKey {
t.Fatalf("expected error 44, got %+v", x)
}
}
func TestAPIKeyAuthenticationRevokedKey(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
rawKey, _ := vsauth.GenerateAPIKey()
key, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "")
if err != nil {
t.Fatal(err)
}
if err := database.RevokeAPIKey(db, key.ID); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Router())
defer srv.Close()
q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}}
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrInvalidAPIKey {
t.Fatalf("expected error 44 for revoked key, got %+v", x)
}
}
func TestAPIKeyAuthenticationConflictsWithUsername(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
rawKey, _ := vsauth.GenerateAPIKey()
if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Router())
defer srv.Close()
q := url.Values{"apiKey": {rawKey}, "u": {"alice"}, "v": {"1.16.1"}, "c": {"test"}}
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrConflictingAuth {
t.Fatalf("expected error 43, got %+v", x)
}
}
func TestAPIKeyDisabledUser(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
rawKey, _ := vsauth.GenerateAPIKey()
if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil {
t.Fatal(err)
}
if err := database.SetUserDisabled(db, u.ID, true); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Router())
defer srv.Close()
q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}}
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrInvalidAPIKey {
t.Fatalf("expected error 44 for disabled user's key, got %+v", x)
}
}
func TestTokenInfo(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
rawKey, _ := vsauth.GenerateAPIKey()
if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Router())
defer srv.Close()
q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}, "f": {"json"}}
resp := getPath(t, srv.URL, "/rest/tokenInfo.view", q)
j := decodeJSON(t, resp)
info, ok := j["tokenInfo"].(map[string]any)
if !ok || info["username"] != "alice" {
t.Fatalf("tokenInfo = %v, want username alice", j)
}
}
func TestAPIKeyTouchesLastUsed(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
rawKey, _ := vsauth.GenerateAPIKey()
key, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "")
if err != nil {
t.Fatal(err)
}
if key.LastUsedAt.Valid {
t.Fatal("new key should not have last_used_at set")
}
srv := httptest.NewServer(s.Router())
defer srv.Close()
q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}}
get(t, srv.URL, q)
updated, err := database.GetAPIKey(db, key.ID)
if err != nil {
t.Fatal(err)
}
if !updated.LastUsedAt.Valid {
t.Error("expected last_used_at to be set after use")
}
}
func TestFormPostSuccess(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
req, err := http.NewRequest(http.MethodPost, srv.URL+"/rest/ping.view", strings.NewReader(q.Encode()))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("POST ping failed: %+v", x)
}
}
func TestFormPostBodyTakesPrecedenceOverQuery(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
createTestUser(t, s, "mallory", "password123", false)
// Body says mallory+correct password; query claims a different user.
// The body value must win — this is the documented net/http
// ParseForm precedence (POST-body values are placed before
// query-string values in r.Form), asserted here rather than
// silently relied upon.
q := url.Values{"u": {"someone-else"}, "p": {"wrong"}, "v": {"1.16.1"}, "c": {"test"}}
body := baseParams("mallory", "password123")
req, err := http.NewRequest(http.MethodPost, srv.URL+"/rest/getUser.view?"+q.Encode()+"&username=mallory", strings.NewReader(body.Encode()))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("expected success (authenticated as mallory via body), got %+v", x)
}
}