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
+120
View File
@@ -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
}