diff --git a/internal/config/config.go b/internal/config/config.go index bf403a8..c2ccaa3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,13 @@ type Config struct { SessionSecret string CookieSecure bool + // EnableExternalMetadata opts into looking up MusicBrainz/Cover Art + // Archive data for songs/albums that already carry a locally-tagged + // MusicBrainz ID. Off by default: VibeSonic must work completely + // without Internet access, and this is the only feature that talks + // to the outside world. + EnableExternalMetadata bool + ExtAuth ExtAuthConfig } @@ -53,6 +60,8 @@ func Load() (Config, error) { MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"), SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"), CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true), + + EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false), } cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db")) diff --git a/internal/database/external_metadata_cache.go b/internal/database/external_metadata_cache.go new file mode 100644 index 0000000..c808e91 --- /dev/null +++ b/internal/database/external_metadata_cache.go @@ -0,0 +1,39 @@ +package database + +import ( + "database/sql" + "time" + + "git.noroutes.net/NoRoutes/vibetunes/internal/idgen" +) + +// GetCachedExternalMetadata returns a previously fetched MusicBrainz/ +// Cover Art Archive response for (entityType, mbid, source), if any. +// found is false when nothing has been cached yet — callers decide +// whether/how to refresh, this layer never guesses an age-based expiry. +func GetCachedExternalMetadata(db *sql.DB, entityType, mbid, source string) (dataJSON string, fetchedAt time.Time, found bool, err error) { + var fetchedAtStr string + err = db.QueryRow(`SELECT data_json, fetched_at FROM external_metadata_cache WHERE entity_type = ? AND mbid = ? AND source = ?`, + entityType, mbid, source).Scan(&dataJSON, &fetchedAtStr) + if err == sql.ErrNoRows { + return "", time.Time{}, false, nil + } + if err != nil { + return "", time.Time{}, false, err + } + fetchedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", fetchedAtStr) + return dataJSON, fetchedAt, true, nil +} + +// PutCachedExternalMetadata stores (or replaces) the cached response +// for (entityType, mbid, source). +func PutCachedExternalMetadata(db *sql.DB, entityType, mbid, source, dataJSON string) error { + _, err := db.Exec(` + INSERT INTO external_metadata_cache (id, entity_type, mbid, source, data_json, fetched_at) + VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ON CONFLICT(entity_type, mbid, source) DO UPDATE SET + data_json = excluded.data_json, + fetched_at = excluded.fetched_at`, + idgen.New("emc"), entityType, mbid, source, dataJSON) + return err +} diff --git a/internal/metadataenrich/client.go b/internal/metadataenrich/client.go new file mode 100644 index 0000000..1b927e1 --- /dev/null +++ b/internal/metadataenrich/client.go @@ -0,0 +1,160 @@ +// Package metadataenrich looks up optional supplementary metadata for +// artists and albums that already carry a MusicBrainz ID from local +// tags — from MusicBrainz itself and the Cover Art Archive. It is never +// on any hot path and never required: VibeSonic must work completely +// offline, so every function here degrades to "no data" rather than an +// error when the network is unavailable, and nothing here ever +// fabricates a value the API didn't actually return. +package metadataenrich + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const userAgent = "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)" + +const ( + defaultMusicBrainzBaseURL = "https://musicbrainz.org/ws/2" + defaultCoverArtArchiveBaseURL = "https://coverartarchive.org" +) + +// Client talks to the MusicBrainz and Cover Art Archive HTTP APIs. The +// zero value is usable and talks to the real public services; +// MusicBrainzBaseURL/CoverArtArchiveBaseURL exist so tests can point it +// at an httptest server instead. +type Client struct { + HTTPClient *http.Client + MusicBrainzBaseURL string + CoverArtArchiveBaseURL string +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: 10 * time.Second} +} + +func (c *Client) musicBrainzBaseURL() string { + if c.MusicBrainzBaseURL != "" { + return c.MusicBrainzBaseURL + } + return defaultMusicBrainzBaseURL +} + +func (c *Client) coverArtArchiveBaseURL() string { + if c.CoverArtArchiveBaseURL != "" { + return c.CoverArtArchiveBaseURL + } + return defaultCoverArtArchiveBaseURL +} + +// ArtistData is the (small) subset of a MusicBrainz artist entity +// VibeSonic actually surfaces. Fields MusicBrainz didn't return are +// left as their zero value. +type ArtistData struct { + Disambiguation string `json:"disambiguation"` + Genres []string `json:"genres"` +} + +type mbArtistResponse struct { + Disambiguation string `json:"disambiguation"` + Tags []struct { + Name string `json:"name"` + } `json:"tags"` +} + +// FetchArtist looks up a MusicBrainz artist entity by MBID. A network +// or lookup failure returns an error; there is no fallback value. +func (c *Client) FetchArtist(ctx context.Context, mbid string) (*ArtistData, error) { + url := fmt.Sprintf("%s/artist/%s?fmt=json&inc=tags", c.musicBrainzBaseURL(), mbid) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("musicbrainz: artist %s: HTTP %d", mbid, resp.StatusCode) + } + + var raw mbArtistResponse + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + data := &ArtistData{Disambiguation: raw.Disambiguation} + for _, t := range raw.Tags { + data.Genres = append(data.Genres, t.Name) + } + return data, nil +} + +// CoverArt is the image URLs the Cover Art Archive returned for a +// release's front cover, at three sizes. A blank field means the +// archive simply didn't provide that size, not that VibeSonic +// substituted anything. +type CoverArt struct { + SmallURL string + MediumURL string + LargeURL string +} + +type caaResponse struct { + Images []struct { + Front bool `json:"front"` + Image string `json:"image"` + Thumbnails map[string]string `json:"thumbnails"` + } `json:"images"` +} + +// FetchReleaseCoverArt looks up the Cover Art Archive entry for a +// release MBID and returns its front-cover image URLs. Returns +// (nil, nil) — not an error — when the release has no cover art +// registered, since that's a normal, common outcome, not a failure. +func (c *Client) FetchReleaseCoverArt(ctx context.Context, mbid string) (*CoverArt, error) { + url := fmt.Sprintf("%s/release/%s", c.coverArtArchiveBaseURL(), mbid) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("coverartarchive: release %s: HTTP %d", mbid, resp.StatusCode) + } + + var raw caaResponse + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + for _, img := range raw.Images { + if !img.Front { + continue + } + return &CoverArt{ + SmallURL: img.Thumbnails["small"], + MediumURL: img.Thumbnails["large"], + LargeURL: img.Image, + }, nil + } + return nil, nil +} diff --git a/internal/metadataenrich/client_test.go b/internal/metadataenrich/client_test.go new file mode 100644 index 0000000..8077b64 --- /dev/null +++ b/internal/metadataenrich/client_test.go @@ -0,0 +1,119 @@ +package metadataenrich + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchArtistParsesRealFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/artist/abc-123" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if r.Header.Get("User-Agent") == "" { + t.Error("MusicBrainz requires a non-empty User-Agent") + } + json.NewEncoder(w).Encode(map[string]any{ + "disambiguation": "English rock band", + "tags": []map[string]string{{"name": "rock"}, {"name": "alternative"}}, + }) + })) + defer srv.Close() + + c := &Client{MusicBrainzBaseURL: srv.URL} + data, err := c.FetchArtist(context.Background(), "abc-123") + if err != nil { + t.Fatalf("FetchArtist: %v", err) + } + if data.Disambiguation != "English rock band" { + t.Errorf("disambiguation = %q", data.Disambiguation) + } + if len(data.Genres) != 2 || data.Genres[0] != "rock" { + t.Errorf("genres = %v", data.Genres) + } +} + +func TestFetchArtistHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := &Client{MusicBrainzBaseURL: srv.URL} + if _, err := c.FetchArtist(context.Background(), "does-not-exist"); err == nil { + t.Fatal("expected an error for a 404 from MusicBrainz, got nil") + } +} + +func TestFetchReleaseCoverArtParsesFrontImage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "images": []map[string]any{ + {"front": false, "image": "http://example.com/back.jpg"}, + { + "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 srv.Close() + + c := &Client{CoverArtArchiveBaseURL: srv.URL} + art, err := c.FetchReleaseCoverArt(context.Background(), "release-1") + if err != nil { + t.Fatalf("FetchReleaseCoverArt: %v", err) + } + if art == nil { + t.Fatal("expected cover art, got nil") + } + if art.LargeURL != "http://example.com/full.jpg" { + t.Errorf("LargeURL = %q", art.LargeURL) + } + if art.SmallURL != "http://example.com/small.jpg" || art.MediumURL != "http://example.com/large.jpg" { + t.Errorf("art = %+v", art) + } +} + +func TestFetchReleaseCoverArtNotFoundReturnsNilNotError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := &Client{CoverArtArchiveBaseURL: srv.URL} + art, err := c.FetchReleaseCoverArt(context.Background(), "no-art") + if err != nil { + t.Fatalf("expected nil error for a release with no registered cover art, got %v", err) + } + if art != nil { + t.Errorf("expected nil CoverArt, got %+v", art) + } +} + +func TestFetchReleaseCoverArtNoFrontImage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "images": []map[string]any{ + {"front": false, "image": "http://example.com/back.jpg"}, + }, + }) + })) + defer srv.Close() + + c := &Client{CoverArtArchiveBaseURL: srv.URL} + art, err := c.FetchReleaseCoverArt(context.Background(), "back-only") + if err != nil { + t.Fatal(err) + } + if art != nil { + t.Errorf("expected nil when no image is marked front, got %+v (must never substitute a back cover)", art) + } +} diff --git a/internal/subsonic/browsing_extra.go b/internal/subsonic/browsing_extra.go index 7e2f866..4b1c1ae 100644 --- a/internal/subsonic/browsing_extra.go +++ b/internal/subsonic/browsing_extra.go @@ -88,44 +88,50 @@ func handleGetVideoInfo(rc *Context) *apiError { return nil } -// --- Artist/album info: VibeSonic has no external metadata provider -// (last.fm etc.) in Phase 2, so these honestly return only what's known -// locally — which for biography/images/similar-artists is nothing — -// rather than fabricating placeholder content. --- +// --- Artist/album info: local data only (title/name, and a +// MusicBrainz ID if one was tagged), optionally enriched from +// MusicBrainz/Cover Art Archive when VIBESONIC_ENABLE_EXTERNAL_METADATA +// is set and the entity has a tagged MusicBrainz ID — see enrich.go. +// VibeSonic must work completely offline; enrichment is always +// best-effort and never blocks or fails the response. --- func handleGetArtistInfo(rc *Context) *apiError { - if _, aerr := rc.Params.Required("id"); aerr != nil { + id, aerr := rc.Params.Required("id") + if aerr != nil { return aerr } rc.Response = newResponse() - rc.Response.ArtistInfo = &ArtistInfo{} + rc.Response.ArtistInfo = &ArtistInfo{ArtistInfoBase: artistInfoBase(rc, id)} return nil } func handleGetArtistInfo2(rc *Context) *apiError { - if _, aerr := rc.Params.Required("id"); aerr != nil { + id, aerr := rc.Params.Required("id") + if aerr != nil { return aerr } rc.Response = newResponse() - rc.Response.ArtistInfo2 = &ArtistInfo2{} + rc.Response.ArtistInfo2 = &ArtistInfo2{ArtistInfoBase: artistInfoBase(rc, id)} return nil } func handleGetAlbumInfo(rc *Context) *apiError { - if _, aerr := rc.Params.Required("id"); aerr != nil { + id, aerr := rc.Params.Required("id") + if aerr != nil { return aerr } rc.Response = newResponse() - rc.Response.AlbumInfo = &AlbumInfo{} + rc.Response.AlbumInfo = albumInfo(rc, id) return nil } func handleGetAlbumInfo2(rc *Context) *apiError { - if _, aerr := rc.Params.Required("id"); aerr != nil { + id, aerr := rc.Params.Required("id") + if aerr != nil { return aerr } rc.Response = newResponse() - rc.Response.AlbumInfo = &AlbumInfo{} + rc.Response.AlbumInfo = albumInfo(rc, id) return nil } diff --git a/internal/subsonic/enrich.go b/internal/subsonic/enrich.go new file mode 100644 index 0000000..0917a67 --- /dev/null +++ b/internal/subsonic/enrich.go @@ -0,0 +1,120 @@ +package subsonic + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/metadataenrich" +) + +// enrichmentCacheTTL is how long a cached MusicBrainz/Cover Art Archive +// response is trusted before VibeSonic tries fetching again. This data +// changes rarely, so a long TTL avoids hammering the (rate-limited, +// shared, free) MusicBrainz service on every request. +const enrichmentCacheTTL = 30 * 24 * time.Hour + +var enrichClient = &metadataenrich.Client{} + +// artistInfoBase builds the local ArtistInfoBase for an artist ID, +// filling in the MusicBrainz-derived fields only when external +// metadata is enabled, the artist has a tagged MusicBrainz ID, and a +// (possibly cached) lookup actually succeeds. Any failure along the +// way — unknown id, no mbid, network error, disabled feature — leaves +// those fields blank rather than erroring the whole request or +// inventing a value. +func artistInfoBase(rc *Context, id string) ArtistInfoBase { + db := rc.Server.DB + artist, err := database.GetArtist(db, id) + if err != nil { + return ArtistInfoBase{} + } + base := ArtistInfoBase{MusicBrainzID: artist.MusicBrainzID} + if !rc.Server.Config.EnableExternalMetadata || artist.MusicBrainzID == "" { + return base + } + + data, err := fetchArtistCached(rc.R.Context(), db, artist.MusicBrainzID) + if err != nil || data == nil { + return base + } + base.Biography = data.Disambiguation + return base +} + +// albumInfo builds the local AlbumInfo for an album ID, enriched with +// Cover Art Archive image URLs under the same conditions as +// artistInfoBase. +func albumInfo(rc *Context, id string) *AlbumInfo { + db := rc.Server.DB + album, err := database.GetAlbum(db, id) + if err != nil { + return &AlbumInfo{} + } + info := &AlbumInfo{MusicBrainzID: album.MusicBrainzID} + if !rc.Server.Config.EnableExternalMetadata || album.MusicBrainzID == "" { + return info + } + + art, err := fetchCoverArtCached(rc.R.Context(), db, album.MusicBrainzID) + if err != nil || art == nil { + return info + } + info.SmallImageURL = art.SmallURL + info.MediumImageURL = art.MediumURL + info.LargeImageURL = art.LargeURL + return info +} + +func fetchArtistCached(ctx context.Context, db *sql.DB, mbid string) (*metadataenrich.ArtistData, error) { + if cached, ok := readEnrichmentCache(db, "artist", mbid, "musicbrainz"); ok { + var data metadataenrich.ArtistData + if err := json.Unmarshal([]byte(cached), &data); err == nil { + return &data, nil + } + } + + data, err := enrichClient.FetchArtist(ctx, mbid) + if err != nil { + return nil, err + } + if raw, err := json.Marshal(data); err == nil { + _ = database.PutCachedExternalMetadata(db, "artist", mbid, "musicbrainz", string(raw)) + } + return data, nil +} + +func fetchCoverArtCached(ctx context.Context, db *sql.DB, mbid string) (*metadataenrich.CoverArt, error) { + if cached, ok := readEnrichmentCache(db, "release", mbid, "coverartarchive"); ok { + var art metadataenrich.CoverArt + if err := json.Unmarshal([]byte(cached), &art); err == nil { + return &art, nil + } + } + + art, err := enrichClient.FetchReleaseCoverArt(ctx, mbid) + if err != nil { + return nil, err + } + if art != nil { + if raw, err := json.Marshal(art); err == nil { + _ = database.PutCachedExternalMetadata(db, "release", mbid, "coverartarchive", string(raw)) + } + } + return art, nil +} + +// readEnrichmentCache returns a cached response's JSON body if one +// exists and is still within enrichmentCacheTTL. +func readEnrichmentCache(db *sql.DB, entityType, mbid, source string) (string, bool) { + dataJSON, fetchedAt, found, err := database.GetCachedExternalMetadata(db, entityType, mbid, source) + if err != nil || !found { + return "", false + } + if time.Since(fetchedAt) > enrichmentCacheTTL { + return "", false + } + return dataJSON, true +} diff --git a/internal/subsonic/enrich_test.go b/internal/subsonic/enrich_test.go new file mode 100644 index 0000000..de29ad4 --- /dev/null +++ b/internal/subsonic/enrich_test.go @@ -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) + } +}