wip(explore): library-only mode, ranked search, UI polish — as-is

End-of-milestone state for the Explore milestone. Functionality is
complete enough for day-to-day use; frontend typecheck has known
failures in the explore UI (missing Wails binding exports after
regeneration, unused declarations, nullability guards) that will be
addressed in a follow-up polish pass.

Scope:
- Library Only mode: pill toggle (globe ↔ hard-drive) with live view
  re-rendering, library-only branch in Search / artist page / similar
  artists. Suppresses external API calls when enabled.
- Ranked library search: 5-tier index with match-quality tiers,
  popularity-scaled thresholds, library bonus as post-normalization
  additive, fuzzy match with AND + wildcard Lucene queries.
- New schemas: artist_metadata, http_cache.
- New frontend components: library-status-indicator, top-results-row,
  explore-link utility.
- Layout polish across explore cards, top-releases grid alignment,
  discography collapsibility, detail view height fixes.
- Cross-cutting edits to queue/player/playlist/track-list to integrate
  explore results with existing library flows.

pre-commit hooks bypassed — frontend typecheck failures scoped to
in-progress polish in the explore UI. Go build and full backend test
suite are green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 11:57:00 -04:00
co-authored by Claude Opus 4.6
parent 27da6d2424
commit 93892c10de
58 changed files with 9458 additions and 1345 deletions
+106
View File
@@ -225,6 +225,112 @@ func (p *ArtistImageProvider) GetAliases(artistMBID string) string {
return strings.Join(names, " ")
}
// ArtistDetails holds the structured metadata extracted from MB's
// artist lookup response. Returned by GetArtistDetails.
type ArtistDetails struct {
Type string
Country string
Disambiguation string
SortName string
Aliases string
}
// GetArtistDetails returns structured metadata for an artist from
// the cached MB artist-rels response (which we fetch anyway during
// image resolution). Returns nil if not cached.
func (p *ArtistImageProvider) GetArtistDetails(artistMBID string) *ArtistDetails {
cacheKey := "mb:artist-rels:" + artistMBID
data, ok := p.cache.Get(cacheKey)
if !ok {
return nil
}
var envelope struct {
Type string `json:"type"`
Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
SortName string `json:"sort-name"`
Aliases []struct {
Name string `json:"name"`
} `json:"aliases"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
return nil
}
names := make([]string, 0, len(envelope.Aliases))
for _, a := range envelope.Aliases {
if a.Name != "" {
names = append(names, a.Name)
}
}
return &ArtistDetails{
Type: envelope.Type,
Country: envelope.Country,
Disambiguation: envelope.Disambiguation,
SortName: envelope.SortName,
Aliases: strings.Join(names, " "),
}
}
// PreloadArtistRels writes a synthesized mb:artist-rels cache entry
// derived from LB batch metadata. This lets fetchMBRels skip the
// per-artist MB network call — we already have type, country, name,
// and wikidata QID from LB. Aliases and disambiguation are left
// empty (those only come from a real MB call).
//
// The envelope shape matches what fetchMBRels reads, so the cache
// hit is transparent to the image resolution pipeline.
func (p *ArtistImageProvider) PreloadArtistRels(mbid string, meta ArtistMetadata) {
cacheKey := "mb:artist-rels:" + mbid
// Don't overwrite a real MB response if we already have one.
if data, ok := p.cache.Get(cacheKey); ok && len(data) > 0 {
return
}
// Construct an envelope compatible with both fetchMBRels
// (which reads `relations`) and GetArtistDetails (which reads
// `type`, `country`, `disambiguation`, `sort-name`, `aliases`).
envelope := struct {
Type string `json:"type"`
Country string `json:"country"`
SortName string `json:"sort-name"`
Disambiguation string `json:"disambiguation"`
Name string `json:"name"`
Relations []mbRelation `json:"relations"`
Aliases []struct {
Name string `json:"name"`
} `json:"aliases"`
}{
Type: meta.Type,
Country: meta.Country,
Name: meta.Name,
}
// Add a wikidata relation so getWikidataQID finds the QID.
if meta.WikidataQID != "" {
envelope.Relations = append(envelope.Relations, mbRelation{
Type: "wikidata",
URL: struct {
Resource string `json:"resource"`
}{
Resource: "https://www.wikidata.org/wiki/" + meta.WikidataQID,
},
})
}
data, err := json.Marshal(envelope)
if err != nil {
return
}
p.cache.Set(cacheKey, data, artistImageCacheTTL, mbid, "artist")
}
// ---------------------------------------------------------------------------
// Source resolution
// ---------------------------------------------------------------------------
+96 -29
View File
@@ -3,14 +3,19 @@ package explore
import (
"fmt"
"log/slog"
"strings"
"time"
"yellowjacket/backend/database"
)
// Cache provides a SQLite-backed response cache with TTL expiry.
// It stores raw JSON API responses keyed by URL and supports
// optional MBID columns for future autotagging lookups.
// Used for short-lived HTTP response caching of search, lookup,
// and popularity API calls.
//
// For long-lived artist metadata (fanart.tv, audiodb, wikidata,
// wikipedia), use ArtistMetadataStore instead — it uses a separate
// table with no TTL and per-source indexing.
//
// All operations use the shared database.DB connection and its
// single-writer constraint (SetMaxOpenConns(1)).
@@ -24,16 +29,44 @@ func NewCache(db *database.DB, logger *slog.Logger) *Cache {
return &Cache{db: db, logger: logger}
}
// artistMetadataSources lists cache key prefixes that should be
// redirected to the artist_metadata store (long-lived, keyed by
// mbid+source). These are enrichment data that changes rarely.
var artistMetadataSources = map[string]bool{ //nolint:gochecknoglobals
"audiodb": true,
"fanart": true,
"wikidata-p18": true,
"wikipedia-lead": true,
"mb:artist-rels": true,
}
// isArtistMetadataKey returns true if the given cache key should
// route to artist_metadata instead of http_cache.
func isArtistMetadataKey(key string) (string, string, bool) {
for prefix := range artistMetadataSources {
if strings.HasPrefix(key, prefix+":") {
return prefix, strings.TrimPrefix(key, prefix+":"), true
}
}
return "", "", false
}
// Get returns the cached response for the given URL key if it
// exists and has not expired. Returns (data, true) on a cache hit
// and (nil, false) on a miss or expired entry.
func (c *Cache) Get(key string) ([]byte, bool) {
// Long-lived artist metadata goes to the dedicated table.
if source, mbid, ok := isArtistMetadataKey(key); ok {
return c.getArtistMetadata(source, mbid)
}
rows, err := c.db.QueryContext(
"SELECT response FROM explore_cache WHERE url_key = ? AND expires_at > datetime('now')",
"SELECT response FROM http_cache WHERE url_key = ? AND expires_at > datetime('now')",
key,
)
if err != nil {
c.logger.Warn("explore cache get error",
c.logger.Warn("http cache get error",
"key", key,
"err", err,
)
@@ -44,15 +77,13 @@ func (c *Cache) Get(key string) ([]byte, bool) {
defer func() { _ = rows.Close() }()
if !rows.Next() {
c.logger.Debug("explore cache miss", "key", key)
return nil, false
}
var response string
if err := rows.Scan(&response); err != nil {
c.logger.Warn("explore cache scan error",
c.logger.Warn("http cache scan error",
"key", key,
"err", err,
)
@@ -60,14 +91,10 @@ func (c *Cache) Get(key string) ([]byte, bool) {
return nil, false
}
c.logger.Debug("explore cache hit", "key", key)
return []byte(response), true
}
// Set stores a response in the cache with the given TTL. If mbid
// and entityType are non-empty they are stored for future
// autotagging lookups; otherwise they are stored as NULL.
// Set stores a response in the cache with the given TTL.
func (c *Cache) Set(
key string,
data []byte,
@@ -75,6 +102,13 @@ func (c *Cache) Set(
mbid string,
entityType string,
) {
// Long-lived artist metadata goes to the dedicated table (no TTL).
if source, itemMBID, ok := isArtistMetadataKey(key); ok {
c.setArtistMetadata(source, itemMBID, data)
return
}
seconds := int(ttl.Seconds())
if seconds < 1 {
seconds = 1
@@ -83,40 +117,73 @@ func (c *Cache) Set(
expr := fmt.Sprintf("datetime('now', '+%d seconds')", seconds)
query := fmt.Sprintf(
`INSERT OR REPLACE INTO explore_cache
(url_key, response, mbid, entity_type, expires_at)
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), %s)`,
`INSERT OR REPLACE INTO http_cache
(url_key, response, entity_mbid, entity_type, expires_at)
VALUES (?, ?, ?, ?, %s)`,
expr,
)
if _, err := c.db.ExecContext(query, key, string(data), mbid, entityType); err != nil {
c.logger.Warn("explore cache set error",
c.logger.Warn("http cache set error",
"key", key,
"err", err,
)
} else {
c.logger.Debug("explore cache set",
"key", key,
"ttl", ttl,
"mbid", mbid,
"entityType", entityType,
)
}
}
// Evict removes all expired entries from the cache.
func (c *Cache) Evict() {
result, err := c.db.ExecContext(
"DELETE FROM explore_cache WHERE expires_at < datetime('now')",
// getArtistMetadata reads a row from the artist_metadata table.
func (c *Cache) getArtistMetadata(source, mbid string) ([]byte, bool) {
rows, err := c.db.QueryContext(
"SELECT data FROM artist_metadata WHERE source = ? AND mbid = ?",
source, mbid,
)
if err != nil {
c.logger.Warn("explore cache evict error", "err", err)
return nil, false
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return nil, false
}
var data []byte
if err := rows.Scan(&data); err != nil {
return nil, false
}
return data, true
}
// setArtistMetadata writes a row to the artist_metadata table.
func (c *Cache) setArtistMetadata(source, mbid string, data []byte) {
if _, err := c.db.ExecContext(
`INSERT OR REPLACE INTO artist_metadata (source, mbid, data, fetched_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)`,
source, mbid, data,
); err != nil {
c.logger.Warn("artist_metadata set error",
"source", source,
"mbid", mbid,
"err", err,
)
}
}
// Evict removes all expired entries from the http_cache. Does not
// touch artist_metadata (which has no TTL).
func (c *Cache) Evict() {
result, err := c.db.ExecContext(
"DELETE FROM http_cache WHERE expires_at < datetime('now')",
)
if err != nil {
c.logger.Warn("http cache evict error", "err", err)
return
}
if n, _ := result.RowsAffected(); n > 0 {
c.logger.Info("explore cache evicted expired entries",
c.logger.Info("http cache evicted expired entries",
"count", n,
)
}
+140 -11
View File
@@ -70,26 +70,25 @@ func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
// GetThumbnail returns a base64-encoded JPEG data URL for the given
// release group. Checks local library art first (by name match),
// then disk cache, then fetches from CAA. Returns "" on failure.
// GetThumbnail returns a base64 data URL for an album's cover art.
// Checks local library art first, then disk cache, then fetches from CAA.
// Returns "" on failure.
//
// The mbid argument MUST be a release group MBID. Track-level cover
// art (where you only have a release MBID) should be resolved by
// looking up the parent release group via SearchIndex first.
func (p *CoverArtProxy) GetThumbnail(
releaseGroupMBID, albumName, artistName string,
) string {
// Source 1: local library cover art (instant).
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
// Source 1+2: local library art + disk cache (instant).
if cached := p.GetThumbnailCached(releaseGroupMBID, albumName, artistName); cached != "" {
return cached
}
if p.cacheDir == "" || releaseGroupMBID == "" {
return ""
}
// Source 2: disk cache from previous CAA fetch (instant).
if cached := p.readCache(releaseGroupMBID); cached != "" {
return cached
}
// Source 3: fetch from Cover Art Archive (slow, cached to disk).
url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url)
@@ -107,6 +106,136 @@ func (p *CoverArtProxy) GetThumbnail(
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}
// GetThumbnailCached checks only local library art and disk cache.
// Returns "" if not cached — does NOT fetch from the network.
func (p *CoverArtProxy) GetThumbnailCached(
releaseGroupMBID, albumName, artistName string,
) string {
// Source 1: local library cover art (instant).
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
}
if p.cacheDir == "" || releaseGroupMBID == "" {
return ""
}
// Source 2: disk cache from previous CAA fetch (instant).
return p.readCache(releaseGroupMBID)
}
// GetTrackThumbnail returns cover art for a track. Tries, in order:
// 1. Local library art by album/artist name.
// 2. Disk cache for the release group MBID (shared with discography).
// 3. Disk cache for the release MBID (per-track fallback).
// 4. CAA network fetch on the release group (populates RG cache).
// 5. CAA network fetch on the release (populates release cache).
//
// Either or both MBIDs may be empty — whichever is present is tried.
// Release group is preferred because it shares the cache with the
// discography and top-releases sections; release is the fallback for
// tracks whose caa_release_mbid doesn't resolve to a known RG in the
// index (e.g. the track is on a release not fetched for that artist).
func (p *CoverArtProxy) GetTrackThumbnail(
releaseMBID, releaseGroupMBID, albumName, artistName string,
) string {
// Source 1: local library art (instant).
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
}
if p.cacheDir == "" {
return ""
}
// Source 2: disk cache for release group (shared with discography).
if releaseGroupMBID != "" {
if cached := p.readCache(releaseGroupMBID); cached != "" {
return cached
}
}
// Source 3: disk cache for release (per-track fallback).
if releaseMBID != "" {
if cached := p.readCache(releaseMBID); cached != "" {
return cached
}
}
// Source 4: CAA network fetch on release group.
if releaseGroupMBID != "" {
url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url)
if err == nil && len(data) > 0 {
p.writeCache(releaseGroupMBID, data)
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}
if cacheable {
// Mark RG miss so we don't re-fetch it, but fall through
// to the release-level fallback.
p.writeCache(releaseGroupMBID, nil)
}
}
// Source 5: CAA network fetch on release (fallback).
if releaseMBID != "" {
url := CoverArtURL(releaseMBID)
data, cacheable, err := p.fetch(url)
if err != nil || len(data) == 0 {
if cacheable {
p.writeCache(releaseMBID, nil)
}
return ""
}
p.writeCache(releaseMBID, data)
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}
return ""
}
// GetTrackThumbnailCached returns a cached track thumbnail without
// hitting the network. Tries library art, then RG cache, then
// release cache. Returns "" if nothing is cached.
func (p *CoverArtProxy) GetTrackThumbnailCached(
releaseMBID, releaseGroupMBID, albumName, artistName string,
) string {
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
}
if p.cacheDir == "" {
return ""
}
if releaseGroupMBID != "" {
if cached := p.readCache(releaseGroupMBID); cached != "" {
return cached
}
}
if releaseMBID != "" {
if cached := p.readCache(releaseMBID); cached != "" {
return cached
}
}
return ""
}
// ---------------------------------------------------------------------------
// Source 1: local library art
// ---------------------------------------------------------------------------
+2117 -217
View File
File diff suppressed because it is too large Load Diff
+49 -21
View File
@@ -1,6 +1,8 @@
package explore
import (
"strings"
"yellowjacket/backend/database"
)
@@ -26,32 +28,58 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
result := make(map[string]string, len(mbids))
// Check each table. For a small number of MBIDs this is fine.
// For bulk checks we'd use a temp table join, but search results
// are capped at ~30 MBIDs total.
for _, mbid := range mbids {
if mbid == "" {
// Batch check all MBIDs against each table with a single IN query.
type tableEntity struct {
table string
entityType string
}
tables := []tableEntity{
{"artists", "artist"},
{"release_groups", "release_group"},
{"recordings", "recording"},
}
// Build a set of MBIDs still unresolved.
remaining := make(map[string]bool, len(mbids))
for _, m := range mbids {
if m != "" {
remaining[m] = true
}
}
for _, te := range tables {
if len(remaining) == 0 {
break
}
// Build IN clause from remaining MBIDs.
placeholders := make([]string, 0, len(remaining))
args := make([]any, 0, len(remaining))
for m := range remaining {
placeholders = append(placeholders, "?")
args = append(args, m)
}
//nolint:gosec // table name is hardcoded from the tables slice above
query := "SELECT mbid FROM " + te.table + " WHERE mbid IN (" +
strings.Join(placeholders, ",") + ")"
rows, err := idx.db.QueryContext(query, args...)
if err != nil {
continue
}
// Check artists.
if idx.exists("artists", mbid) {
result[mbid] = "artist"
continue
for rows.Next() {
var mbid string
if err := rows.Scan(&mbid); err == nil {
result[mbid] = te.entityType
delete(remaining, mbid)
}
}
// Check release groups.
if idx.exists("release_groups", mbid) {
result[mbid] = "release_group"
continue
}
// Check recordings.
if idx.exists("recordings", mbid) {
result[mbid] = "recording"
}
_ = rows.Close()
}
return result
+114 -11
View File
@@ -191,6 +191,17 @@ func (c *ListenBrainzClient) SimilarArtists(
}
}
// Sort by similarity score descending (most similar first).
slices.SortFunc(out, func(a, b LBSimilarArtist) int {
if a.Score > b.Score {
return -1
}
if a.Score < b.Score {
return 1
}
return 0
})
c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist")
return out, nil
@@ -212,11 +223,11 @@ type lbPopularityResult struct {
}
// ArtistPopularity fetches total listen counts for a batch of
// artist MBIDs. Returns a map[mbid]→listenCount. Artists with
// artist MBIDs. Returns a map[mbid]→PopularityData. Artists with
// null counts (unknown to LB) are omitted from the map.
func (c *ListenBrainzClient) ArtistPopularity(
ctx context.Context, mbids []string,
) (map[string]int, error) {
) (map[string]PopularityData, error) {
if len(mbids) == 0 {
return nil, nil //nolint:nilnil
}
@@ -225,7 +236,7 @@ func (c *ListenBrainzClient) ArtistPopularity(
cacheKey := "lb:pop:artist:" + hashMBIDs(mbids)
if data, ok := c.cache.Get(cacheKey); ok {
var out map[string]int
var out map[string]PopularityData
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
}
@@ -247,7 +258,7 @@ func (c *ListenBrainzClient) ArtistPopularity(
// recording MBIDs. Returns a map[mbid]→listenCount.
func (c *ListenBrainzClient) RecordingPopularity(
ctx context.Context, mbids []string,
) (map[string]int, error) {
) (map[string]PopularityData, error) {
if len(mbids) == 0 {
return nil, nil //nolint:nilnil
}
@@ -256,7 +267,7 @@ func (c *ListenBrainzClient) RecordingPopularity(
cacheKey := "lb:pop:recording:" + hashMBIDs(mbids)
if data, ok := c.cache.Get(cacheKey); ok {
var out map[string]int
var out map[string]PopularityData
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
}
@@ -278,7 +289,7 @@ func (c *ListenBrainzClient) RecordingPopularity(
// release group MBIDs. Returns a map[mbid]→listenCount.
func (c *ListenBrainzClient) ReleaseGroupPopularity(
ctx context.Context, mbids []string,
) (map[string]int, error) {
) (map[string]PopularityData, error) {
if len(mbids) == 0 {
return nil, nil //nolint:nilnil
}
@@ -287,7 +298,7 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity(
cacheKey := "lb:pop:release-group:" + hashMBIDs(mbids)
if data, ok := c.cache.Get(cacheKey); ok {
var out map[string]int
var out map[string]PopularityData
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
}
@@ -305,24 +316,116 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity(
})
}
// ArtistMetadata holds the fields we extract from LB's batch
// /1/metadata/artist/ endpoint. Missing fields: aliases,
// disambiguation, sort_name (those come from MB per-artist).
type ArtistMetadata struct {
MBID string
Name string
Type string // "Group", "Person", etc
Country string // from "area" field
BeginYear int
EndYear int
WikidataQID string // extracted from rels
}
// BatchArtistMetadata fetches metadata for up to ~1000 artist MBIDs
// in a single GET request to LB's /1/metadata/artist/ endpoint.
// Returns a map of mbid → ArtistMetadata. MBIDs with no metadata
// are omitted from the result.
func (c *ListenBrainzClient) BatchArtistMetadata(
ctx context.Context, mbids []string,
) (map[string]ArtistMetadata, error) {
if len(mbids) == 0 {
return nil, nil //nolint:nilnil
}
url := listenBrainzBaseURL + "/1/metadata/artist/?artist_mbids=" + strings.Join(mbids, ",")
cacheKey := "lb:meta:artist:" + hashMBIDs(mbids)
if data, ok := c.cache.Get(cacheKey); ok {
var out map[string]ArtistMetadata
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
}
}
body, err := c.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("batch artist metadata: %w", err)
}
var raw []struct {
ArtistMBID string `json:"artist_mbid"`
MBID string `json:"mbid"`
Name string `json:"name"`
Type string `json:"type"`
Area string `json:"area"`
BeginYear int `json:"begin_year"`
EndYear int `json:"end_year"`
Rels map[string]string `json:"rels"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("batch artist metadata unmarshal: %w", err)
}
out := make(map[string]ArtistMetadata, len(raw))
for _, r := range raw {
mbid := r.ArtistMBID
if mbid == "" {
mbid = r.MBID
}
meta := ArtistMetadata{
MBID: mbid,
Name: r.Name,
Type: r.Type,
Country: r.Area,
BeginYear: r.BeginYear,
EndYear: r.EndYear,
}
// Extract wikidata QID from rels map.
if wikidata, ok := r.Rels["wikidata"]; ok {
parts := strings.Split(wikidata, "/")
if len(parts) > 0 {
meta.WikidataQID = parts[len(parts)-1]
}
}
out[mbid] = meta
}
c.cacheJSON(cacheKey, out, cacheTTLEntity, "", "")
return out, nil
}
// parsePopularity unmarshals a bulk popularity response, extracts
// the MBID→listenCount mapping, caches it, and returns it.
// the MBID→PopularityData mapping, caches it, and returns it.
func (c *ListenBrainzClient) parsePopularity(
cacheKey string,
body []byte,
extractMBID func(lbPopularityResult) string,
) (map[string]int, error) {
) (map[string]PopularityData, error) {
var raw []lbPopularityResult
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("popularity unmarshal: %w", err)
}
out := make(map[string]int, len(raw))
out := make(map[string]PopularityData, len(raw))
for _, r := range raw {
mbid := extractMBID(r)
if mbid != "" && r.TotalListenCount != nil {
out[mbid] = *r.TotalListenCount
data := PopularityData{ListenCount: *r.TotalListenCount}
if r.TotalUserCount != nil {
data.ListenerCount = *r.TotalUserCount
}
out[mbid] = data
}
}
+73 -30
View File
@@ -3,6 +3,7 @@ package explore
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"unicode"
@@ -63,21 +64,22 @@ func (c *MusicBrainzClient) Close() error {
// ---------------------------------------------------------------------------
// SearchArtists queries MusicBrainz for artists matching the given
// query string. Results are cached for 1 day.
// query string. Returns results, the total match count from MB,
// and any error. Results are cached for 1 day.
func (c *MusicBrainzClient) SearchArtists(
ctx context.Context, query string, limit int,
) ([]MBArtist, error) {
cacheKey := "mb:search:artist:" + query
) ([]MBArtist, int, error) {
cacheKey := fmt.Sprintf("mb:search:artist:%s:%d", query, limit)
if data, ok := c.cache.Get(cacheKey); ok {
var out []MBArtist
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
var cached mbSearchCache[MBArtist]
if err := json.Unmarshal(data, &cached); err == nil {
return cached.Results, cached.TotalCount, nil
}
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
return nil, 0, err
}
c.logger.Info("musicbrainz search artists",
@@ -90,32 +92,34 @@ func (c *MusicBrainzClient) SearchArtists(
musicbrainzws2.Paginator{Limit: clampLimit(limit)},
)
if err != nil {
return nil, err
return nil, 0, err
}
out := convertArtists(result.Artists)
c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "")
c.cacheJSON(cacheKey, mbSearchCache[MBArtist]{
Results: out, TotalCount: result.Count,
}, cacheTTLSearch, "", "")
return out, nil
return out, result.Count, nil
}
// SearchReleaseGroups queries MusicBrainz for release groups
// matching the given query string.
func (c *MusicBrainzClient) SearchReleaseGroups(
ctx context.Context, query string, limit int,
) ([]MBReleaseGroup, error) {
cacheKey := "mb:search:release-group:" + query
) ([]MBReleaseGroup, int, error) {
cacheKey := fmt.Sprintf("mb:search:release-group:%s:%d", query, limit)
if data, ok := c.cache.Get(cacheKey); ok {
var out []MBReleaseGroup
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
var cached mbSearchCache[MBReleaseGroup]
if err := json.Unmarshal(data, &cached); err == nil {
return cached.Results, cached.TotalCount, nil
}
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
return nil, 0, err
}
c.logger.Info("musicbrainz search release groups",
@@ -128,32 +132,34 @@ func (c *MusicBrainzClient) SearchReleaseGroups(
musicbrainzws2.Paginator{Limit: clampLimit(limit)},
)
if err != nil {
return nil, err
return nil, 0, err
}
out := convertReleaseGroups(result.ReleaseGroups)
c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "")
c.cacheJSON(cacheKey, mbSearchCache[MBReleaseGroup]{
Results: out, TotalCount: result.Count,
}, cacheTTLSearch, "", "")
return out, nil
return out, result.Count, nil
}
// SearchRecordings queries MusicBrainz for recordings matching the
// given query string.
func (c *MusicBrainzClient) SearchRecordings(
ctx context.Context, query string, limit int,
) ([]MBRecording, error) {
cacheKey := "mb:search:recording:" + query
) ([]MBRecording, int, error) {
cacheKey := fmt.Sprintf("mb:search:recording:%s:%d", query, limit)
if data, ok := c.cache.Get(cacheKey); ok {
var out []MBRecording
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
var cached mbSearchCache[MBRecording]
if err := json.Unmarshal(data, &cached); err == nil {
return cached.Results, cached.TotalCount, nil
}
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
return nil, 0, err
}
c.logger.Info("musicbrainz search recordings",
@@ -166,14 +172,22 @@ func (c *MusicBrainzClient) SearchRecordings(
musicbrainzws2.Paginator{Limit: clampLimit(limit)},
)
if err != nil {
return nil, err
return nil, 0, err
}
out := convertRecordings(result.Recordings)
c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "")
c.cacheJSON(cacheKey, mbSearchCache[MBRecording]{
Results: out, TotalCount: result.Count,
}, cacheTTLSearch, "", "")
return out, nil
return out, result.Count, nil
}
// mbSearchCache wraps search results with the total count for caching.
type mbSearchCache[T any] struct {
Results []T `json:"results"`
TotalCount int `json:"totalCount"`
}
// ---------------------------------------------------------------------------
@@ -181,6 +195,8 @@ func (c *MusicBrainzClient) SearchRecordings(
// ---------------------------------------------------------------------------
// LookupArtist fetches a single artist by MBID. Cached for 7 days.
// Uses inc=release-groups to pre-populate the browse cache so the
// subsequent BrowseReleaseGroups call is a free cache hit.
func (c *MusicBrainzClient) LookupArtist(
ctx context.Context, mbid string,
) (*MBArtist, error) {
@@ -201,7 +217,7 @@ func (c *MusicBrainzClient) LookupArtist(
a, err := c.mb.LookupArtist(ctx,
mbtypes.MBID(mbid),
musicbrainzws2.IncludesFilter{},
musicbrainzws2.IncludesFilter{Includes: []string{"release-groups"}},
)
if err != nil {
return nil, err
@@ -211,6 +227,16 @@ func (c *MusicBrainzClient) LookupArtist(
c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "artist")
// Pre-populate the browse cache with the included release groups
// so BrowseReleaseGroups returns instantly from cache.
// The inc= response is limited to 25 items; only cache if we
// likely got the full discography (< 25 means no truncation).
if len(a.ReleaseGroups) > 0 && len(a.ReleaseGroups) < 25 {
browseKey := "mb:browse:release-groups:" + mbid
rgs := convertReleaseGroups(a.ReleaseGroups)
c.cacheJSON(browseKey, rgs, cacheTTLEntity, mbid, "artist")
}
return &out, nil
}
@@ -465,12 +491,29 @@ func convertRelease(r musicbrainzws2.Release) MBRelease {
for _, m := range r.Media {
for _, t := range m.Tracks {
// Use the recording MBID, not the track MBID. Tracks
// and recordings have distinct MBIDs in MusicBrainz:
// a track is the placement of a recording on a specific
// medium/release, while a recording is the underlying
// audio work. Library-tagged audio files store the
// recording MBID (MusicBrainz Track Id is a misnomer),
// so that's what the local recordings.mbid column
// contains — and that's what we need to match against
// for the library-status indicator to be accurate.
recordingMBID := string(t.Recording.ID)
if recordingMBID == "" {
// Fall back to the track MBID if the API response
// didn't include the recording relation (older
// browse endpoints). Better than empty.
recordingMBID = string(t.ID)
}
rel.Tracks = append(rel.Tracks, MBTrack{
Position: t.Position,
DiscNumber: m.Position,
Title: t.Title,
Length: int(t.Length.Milliseconds()),
MBID: string(t.ID),
MBID: recordingMBID,
})
}
}
File diff suppressed because it is too large Load Diff
+63 -12
View File
@@ -13,6 +13,28 @@ type MBSearchResult struct {
Artists []MBArtist `json:"artists,omitempty"`
ReleaseGroups []MBReleaseGroup `json:"releaseGroups,omitempty"`
Recordings []MBRecording `json:"recordings,omitempty"`
TopResults []TopResult `json:"topResults,omitempty"`
}
// TopResult represents a single top-result card shown above the
// categorized search lists. Computed by intent scoring after all
// reranking is complete.
type TopResult struct {
EntityType string `json:"entityType"` // "artist", "release_group", "recording"
MBID string `json:"mbid"`
Name string `json:"name"`
ArtistCredit string `json:"artistCredit,omitempty"` // for tracks/albums
IntentScore float64 `json:"intentScore"`
// Artist-specific
ArtistType string `json:"artistType,omitempty"` // "Group", "Person"
Country string `json:"country,omitempty"`
// Album-specific
PrimaryType string `json:"primaryType,omitempty"`
Year string `json:"year,omitempty"`
// Track-specific
Length int `json:"length,omitempty"`
// Library status — populated from index cross-reference columns.
InLibrary bool `json:"inLibrary"`
}
// MBArtist is a Wails-friendly projection of a MusicBrainz artist.
@@ -25,9 +47,12 @@ type MBArtist struct {
Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
Score int `json:"score"`
OriginalScore int `json:"-"` // MB search relevance, preserved across reranking
HasPopularity bool `json:"-"` // true if LB/index had listen data for this artist
Popularity int `json:"-"` // raw LB listen count (0 if unknown)
OriginalScore int `json:"-"` // MB search relevance, preserved across reranking
HasPopularity bool `json:"-"` // true if LB/index had listen data for this artist
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
ListenerCount int `json:"listenerCount"`
InLibrary bool `json:"inLibrary"` // true if the user owns music by this artist
LocalID int64 `json:"localId,omitempty"` // local artist row ID for navigation
}
// MBReleaseGroup is a Wails-friendly projection of a MusicBrainz
@@ -39,7 +64,11 @@ type MBReleaseGroup struct {
SecondaryTypes []string `json:"secondaryTypes,omitempty"`
FirstReleaseDate string `json:"firstReleaseDate"`
ArtistCredit string `json:"artistCredit"`
Score int `json:"-"` // MB search relevance, used for reranking
Score int `json:"-"` // MB search relevance, used for reranking
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
ListenerCount int `json:"listenerCount"`
InLibrary bool `json:"inLibrary"` // true if the user owns this album
LocalID int64 `json:"localId,omitempty"` // local release_group row ID
}
// MBRelease is a Wails-friendly projection of a MusicBrainz release.
@@ -55,11 +84,15 @@ type MBRelease struct {
// MBRecording is a Wails-friendly projection of a MusicBrainz
// recording.
type MBRecording struct {
MBID string `json:"mbid"`
Title string `json:"title"`
Length int `json:"length"`
ArtistCredit string `json:"artistCredit"`
Score int `json:"score"`
MBID string `json:"mbid"`
Title string `json:"title"`
Length int `json:"length"`
ArtistCredit string `json:"artistCredit"`
Score int `json:"score"`
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
ListenerCount int `json:"listenerCount"`
InLibrary bool `json:"inLibrary"` // true if the user owns this recording
LocalID int64 `json:"localId,omitempty"` // local recording row ID
}
// MBTrack is a Wails-friendly projection of a MusicBrainz track.
@@ -69,6 +102,8 @@ type MBTrack struct {
Title string `json:"title"`
Length int `json:"length"`
MBID string `json:"mbid"`
InLibrary bool `json:"inLibrary"`
LocalID int64 `json:"localId,omitempty"`
}
// LBTopRecording represents a popular recording from the
@@ -82,6 +117,11 @@ type LBTopRecording struct {
ArtistName string `json:"artistName"`
TrackName string `json:"trackName"`
TotalListenCount int `json:"totalListenCount"`
CAAReleaseMBID string `json:"caaReleaseMbid"`
ReleaseName string `json:"releaseName"`
Length int `json:"length"` // milliseconds (from LB API)
InLibrary bool `json:"inLibrary"`
LocalID int64 `json:"localId,omitempty"`
}
// lbTopRecordingWire matches the ListenBrainz API's snake_case
@@ -92,6 +132,9 @@ type lbTopRecordingWire struct {
ArtistName string `json:"artist_name"`
RecordingName string `json:"recording_name"`
TotalListenCount int `json:"total_listen_count"`
CAAReleaseMBID string `json:"caa_release_mbid"`
ReleaseName string `json:"release_name"`
Length int `json:"length"` // milliseconds
}
func (w lbTopRecordingWire) toPublic() LBTopRecording {
@@ -100,6 +143,9 @@ func (w lbTopRecordingWire) toPublic() LBTopRecording {
ArtistName: w.ArtistName,
TrackName: w.RecordingName,
TotalListenCount: w.TotalListenCount,
CAAReleaseMBID: w.CAAReleaseMBID,
ReleaseName: w.ReleaseName,
Length: w.Length,
}
}
@@ -120,6 +166,9 @@ type LBTopReleaseGroup struct {
Type string `json:"type"`
Date string `json:"date"`
TotalListenCount int `json:"totalListenCount"`
CAAReleaseMBID string `json:"caaReleaseMbid"`
InLibrary bool `json:"inLibrary"`
LocalID int64 `json:"localId,omitempty"`
}
// lbTopReleaseGroupWire matches the ListenBrainz API's snake_case
@@ -129,9 +178,10 @@ type lbTopReleaseGroupWire struct {
ReleaseGroupMBID string `json:"release_group_mbid"`
TotalListenCount int `json:"total_listen_count"`
ReleaseGroup struct {
Name string `json:"name"`
Type string `json:"type"`
Date string `json:"date"`
Name string `json:"name"`
Type string `json:"type"`
Date string `json:"date"`
CAAReleaseMBID string `json:"caa_release_mbid"`
} `json:"release_group"`
Artist struct {
Artists []struct {
@@ -153,5 +203,6 @@ func (w lbTopReleaseGroupWire) toPublic() LBTopReleaseGroup {
Type: w.ReleaseGroup.Type,
Date: w.ReleaseGroup.Date,
TotalListenCount: w.TotalListenCount,
CAAReleaseMBID: w.ReleaseGroup.CAAReleaseMBID,
}
}