Add optional MusicBrainz/Cover Art Archive metadata enrichment

getArtistInfo/2 and getAlbumInfo/2 now return a real MusicBrainz
disambiguation/cover-art lookup for entities that carry a locally
tagged MusicBrainz ID, gated behind the new
VIBESONIC_ENABLE_EXTERNAL_METADATA flag (default off). Responses are
cached in the existing external_metadata_cache table for 30 days to
avoid hammering the free MusicBrainz/CAA services.

VibeSonic still works completely offline: with the flag off, or when
an entity has no tagged MusicBrainz ID, or on any network/lookup
failure, the handlers fall back to local-only fields exactly as
before — nothing is ever fabricated, and a failed external lookup
never fails the request.

internal/metadataenrich is a small, independently-testable HTTP client
for the two real APIs (base URLs overridable for tests, exercised
against httptest servers rather than the live network).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
This commit is contained in:
2026-08-23 08:35:40 -05:00
co-authored by Claude Sonnet 5
parent 2a69f6dc33
commit a921f9e2bc
7 changed files with 649 additions and 12 deletions
+184
View File
@@ -0,0 +1,184 @@
package subsonic
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadataenrich"
)
// withEnrichClient temporarily swaps the package-level enrichClient for
// a test double and restores it afterward, since tests run
// sequentially in this package.
func withEnrichClient(t *testing.T, c *metadataenrich.Client) {
t.Helper()
orig := enrichClient
enrichClient = c
t.Cleanup(func() { enrichClient = orig })
}
func TestArtistInfo2EnrichedWhenEnabled(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
artist, err := database.FindOrCreateArtist(db, "Test Artist", "", "mbid-artist-1")
if err != nil {
t.Fatal(err)
}
var calls int32
mb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
json.NewEncoder(w).Encode(map[string]any{"disambiguation": "a real band"})
}))
defer mb.Close()
withEnrichClient(t, &metadataenrich.Client{MusicBrainzBaseURL: mb.URL})
s.Config.EnableExternalMetadata = true
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", artist.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getArtistInfo2.view", q)
j := decodeJSON(t, resp)
info, ok := j["artistInfo2"].(map[string]any)
if !ok || info["biography"] != "a real band" {
t.Fatalf("artistInfo2 = %v, want biography from the mocked MusicBrainz response", j)
}
if info["musicBrainzId"] != "mbid-artist-1" {
t.Errorf("musicBrainzId = %v", info["musicBrainzId"])
}
// A second lookup must hit the cache, not MusicBrainz again.
getPath(t, srv, "/rest/getArtistInfo2.view", q)
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("MusicBrainz was called %d times, want 1 (second lookup should be served from cache)", got)
}
}
func TestArtistInfo2NotEnrichedWhenDisabled(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
artist, err := database.FindOrCreateArtist(db, "Test Artist", "", "mbid-artist-2")
if err != nil {
t.Fatal(err)
}
called := false
mb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
json.NewEncoder(w).Encode(map[string]any{"disambiguation": "should never be fetched"})
}))
defer mb.Close()
withEnrichClient(t, &metadataenrich.Client{MusicBrainzBaseURL: mb.URL})
// EnableExternalMetadata left false (the default): must never
// call out to the network, matching "must work completely offline".
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", artist.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getArtistInfo2.view", q)
j := decodeJSON(t, resp)
info := j["artistInfo2"].(map[string]any)
if _, has := info["biography"]; has {
t.Errorf("biography should be absent when external metadata is disabled, got %v", info)
}
if called {
t.Error("MusicBrainz must never be called when external metadata is disabled")
}
}
func TestArtistInfo2NoMusicBrainzIDNeverCallsNetwork(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
artist, err := database.FindOrCreateArtist(db, "Untagged Artist", "", "")
if err != nil {
t.Fatal(err)
}
called := false
mb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer mb.Close()
withEnrichClient(t, &metadataenrich.Client{MusicBrainzBaseURL: mb.URL})
s.Config.EnableExternalMetadata = true
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", artist.ID)
q.Set("f", "json")
getPath(t, srv, "/rest/getArtistInfo2.view", q)
if called {
t.Error("must never look up an artist with no locally-tagged MusicBrainz ID")
}
}
func TestArtistInfo2NetworkFailureLeavesFieldsBlank(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
artist, err := database.FindOrCreateArtist(db, "Test Artist", "", "mbid-artist-3")
if err != nil {
t.Fatal(err)
}
mb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer mb.Close()
withEnrichClient(t, &metadataenrich.Client{MusicBrainzBaseURL: mb.URL})
s.Config.EnableExternalMetadata = true
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", artist.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getArtistInfo2.view", q)
j := decodeJSON(t, resp)
if j["status"] != nil && j["status"] != "ok" {
t.Fatalf("a MusicBrainz failure must not fail the whole response: %v", j)
}
info := j["artistInfo2"].(map[string]any)
if _, has := info["biography"]; has {
t.Errorf("biography should be absent after a fetch failure, got %v", info)
}
}
func TestAlbumInfo2EnrichedCoverArt(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
album, err := database.FindOrCreateAlbum(db, "Test Album", sql.NullString{}, "", sql.NullInt64{}, sql.NullInt64{}, "", "mbid-release-1")
if err != nil {
t.Fatal(err)
}
caa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"images": []map[string]any{
{"front": true, "image": "http://example.com/full.jpg", "thumbnails": map[string]string{
"small": "http://example.com/small.jpg", "large": "http://example.com/large.jpg",
}},
},
})
}))
defer caa.Close()
withEnrichClient(t, &metadataenrich.Client{CoverArtArchiveBaseURL: caa.URL})
s.Config.EnableExternalMetadata = true
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", album.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getAlbumInfo2.view", q)
j := decodeJSON(t, resp)
info, ok := j["albumInfo"].(map[string]any)
if !ok || info["largeImageUrl"] != "http://example.com/full.jpg" {
t.Fatalf("albumInfo = %v, want cover art from the mocked Cover Art Archive response", j)
}
}