wip on autotagging

This commit is contained in:
2026-05-01 11:52:50 -04:00
parent 5cf019a0ac
commit d5140395da
295 changed files with 11105 additions and 40714 deletions
+137
View File
@@ -0,0 +1,137 @@
package explore
import (
"context"
"yellowjacket/backend/autotag"
)
// AutotagClient adapts *MusicBrainzClient to autotag.MBClient.
// Lives in the explore package so autotag stays free of
// explore-internal types — callers in app wiring construct one via
// NewAutotagClient and hand it to the scorer.
type AutotagClient struct {
inner *MusicBrainzClient
}
// NewAutotagClient wraps a MusicBrainzClient for use by the
// autotag scorer.
func NewAutotagClient(inner *MusicBrainzClient) *AutotagClient {
return &AutotagClient{inner: inner}
}
// SearchReleaseGroups delegates to the wrapped client and projects
// hits into autotag's minimal shape.
func (c *AutotagClient) SearchReleaseGroups(
ctx context.Context, query string, limit int,
) ([]autotag.MBReleaseGroupHit, int, error) {
hits, total, err := c.inner.SearchReleaseGroups(ctx, query, limit)
if err != nil {
return nil, 0, err
}
out := make([]autotag.MBReleaseGroupHit, 0, len(hits))
for _, h := range hits {
out = append(out, autotag.MBReleaseGroupHit{
MBID: h.MBID,
Title: h.Title,
ArtistCredit: h.ArtistCredit,
FirstDate: h.FirstReleaseDate,
PrimaryType: h.PrimaryType,
})
}
return out, total, nil
}
// BrowseReleases delegates to the wrapped client and projects each
// release (and its tracks) into autotag's shape. Length is
// millisecond-aligned to match local audio_files.
func (c *AutotagClient) BrowseReleases(
ctx context.Context, releaseGroupMBID string,
) ([]autotag.MBRelease, error) {
releases, err := c.inner.BrowseReleases(ctx, releaseGroupMBID)
if err != nil {
return nil, err
}
out := make([]autotag.MBRelease, 0, len(releases))
for _, rel := range releases {
out = append(out, exploreToAutotagRelease(rel))
}
return out, nil
}
// LookupRelease fetches a single release by MBID and projects it
// into autotag's shape.
func (c *AutotagClient) LookupRelease(
ctx context.Context, releaseMBID string,
) (autotag.MBRelease, error) {
rel, err := c.inner.LookupRelease(ctx, releaseMBID)
if err != nil {
return autotag.MBRelease{}, err
}
return exploreToAutotagRelease(*rel), nil
}
// LookupReleaseGroup fetches a single release group by MBID and
// projects it into autotag's hit shape.
func (c *AutotagClient) LookupReleaseGroup(
ctx context.Context, releaseGroupMBID string,
) (autotag.MBReleaseGroupHit, error) {
rg, err := c.inner.LookupReleaseGroup(ctx, releaseGroupMBID)
if err != nil {
return autotag.MBReleaseGroupHit{}, err
}
return autotag.MBReleaseGroupHit{
MBID: rg.MBID,
Title: rg.Title,
ArtistCredit: rg.ArtistCredit,
FirstDate: rg.FirstReleaseDate,
PrimaryType: rg.PrimaryType,
}, nil
}
// LookupArtist returns the artist's sort name when available,
// falling back to the display name. Used by the resolver when
// constructing Lucene-style fallback queries.
func (c *AutotagClient) LookupArtist(
ctx context.Context, mbid string,
) (string, error) {
a, err := c.inner.LookupArtist(ctx, mbid)
if err != nil {
return "", err
}
if a.SortName != "" {
return a.SortName, nil
}
return a.Name, nil
}
func exploreToAutotagRelease(rel MBRelease) autotag.MBRelease {
tracks := make([]autotag.CandidateTrack, 0, len(rel.Tracks))
for _, t := range rel.Tracks {
tracks = append(tracks, autotag.CandidateTrack{
Position: t.Position,
DiscNumber: t.DiscNumber,
Title: t.Title,
LengthMillis: int64(t.Length),
MBID: t.MBID,
})
}
return autotag.MBRelease{
MBID: rel.MBID,
Title: rel.Title,
Date: rel.Date,
Country: rel.Country,
Status: rel.Status,
ArtistCredit: rel.ArtistCredit,
Tracks: tracks,
}
}
+152
View File
@@ -0,0 +1,152 @@
package explore
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/jpeg" // Register decoder.
_ "image/png" // Register decoder.
"io"
"log/slog"
"net/http"
"os"
"time"
"yellowjacket/backend/metadata"
)
// minCoverArtDimensionPx is the minimum size on the shortest side
// of a cover-art image the autotagger will embed. Below this the
// image is considered too low-res for desktop/mobile display.
// REVIEW-05 in the 010 plan pins this; 012 may surface it as a
// config option.
const minCoverArtDimensionPx = 500
// caaFetchTimeout bounds the single CAA GET. CAA redirects to
// archive.org, which can be slow but shouldn't stall apply for
// minutes.
const caaFetchTimeout = 30 * time.Second
// errCAANot2xx signals a CAA response that wasn't 200 or 404.
var errCAANot2xx = errors.New("autotag cover art: unexpected CAA status")
// AutotagCoverArt implements autotag.CoverArtEmbedder against the
// Cover Art Archive. Rule: never replace existing art; only embed
// when the file has none AND CAA returns an image ≥500 px on the
// shortest side.
type AutotagCoverArt struct {
limiter *RateLimiter
logger *slog.Logger
httpCli *http.Client
}
// NewAutotagCoverArt wires up the embedder with the shared CAA
// limiter. httpClient may be nil — a default 30 s client is used.
func NewAutotagCoverArt(limiter *RateLimiter, logger *slog.Logger) *AutotagCoverArt {
return &AutotagCoverArt{
limiter: limiter,
logger: logger,
httpCli: &http.Client{Timeout: caaFetchTimeout},
}
}
// FetchArt is the network half of autotag.CoverArtEmbedder. Hits
// CAA for the release group, validates the result is at least
// 500 px on the shortest side, and returns the raw bytes ready to
// embed. Returns (nil, nil) when CAA has nothing or the result is
// below the minimum size; (nil, err) when the network or decode
// failed. Caller is expected to invoke this once per album and
// reuse the bytes across every track that lacks embedded art.
func (c *AutotagCoverArt) FetchArt(
ctx context.Context, releaseGroupMBID string,
) ([]byte, error) {
if releaseGroupMBID == "" {
return nil, nil
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("wait for CAA limiter: %w", err)
}
url := CoverArtGroupURLSize(releaseGroupMBID, minCoverArtDimensionPx*2) //nolint:mnd
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build CAA request: %w", err)
}
resp, err := c.httpCli.Do(req)
if err != nil {
return nil, fmt.Errorf("CAA GET: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d for %s", errCAANot2xx, resp.StatusCode, url)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CAA body: %w", err)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("decode CAA image: %w", err)
}
shortest := cfg.Width
if cfg.Height < shortest {
shortest = cfg.Height
}
if shortest < minCoverArtDimensionPx {
c.logger.Debug(
"cover art: CAA result below 500px, skipping",
"width", cfg.Width, "height", cfg.Height,
"release_group_mbid", releaseGroupMBID,
)
return nil, nil
}
return body, nil
}
// HasEmbeddedArt reports whether the local audio file already
// carries embedded picture data. The autotag pipeline calls this
// per track to decide whether to merge the album's CAA art into
// that track's changes — never replacing existing art is the
// invariant. If metadata extraction fails the function returns
// false (safer default: fall through to "no art present, may
// embed" — the writer still won't overwrite anything because the
// tag-level diff is built from this signal).
func (c *AutotagCoverArt) HasEmbeddedArt(filePath string) bool {
if _, err := os.Stat(filePath); err != nil {
c.logger.Debug(
"cover art: stat for embedded-art probe failed",
"path", filePath, "err", err,
)
return false
}
tags, _, _, _, err := metadata.ExtractAllMetadata(filePath, true)
if err != nil {
c.logger.Debug(
"cover art: extract for embedded-art probe failed",
"path", filePath, "err", err,
)
return false
}
return tags != nil && tags.Picture != nil && len(tags.Picture.Data) > 0
}
+64 -124
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -34,24 +33,34 @@ const (
)
// CoverArtProxy fetches and caches cover art thumbnails locally.
// It checks three sources in order:
// 1. Local library cover art (instant, matched by album+artist name)
// 2. Disk cache from a previous CAA fetch (instant)
// 3. Cover Art Archive network fetch (slow, cached to disk)
// It checks two sources in order:
// 1. Disk cache from a previous CAA fetch (instant)
// 2. Cover Art Archive network fetch (slow, cached to disk)
//
// The proxy used to also consult the local library's cover_art
// table by album/artist name, but that path conflated externally-
// fetched art with audio-file embedded ID3 art (the cover_art
// table writes both with is_embedded=true, so they're
// indistinguishable downstream). For autotag review and explore
// browsing we want the canonical CAA cover, not whatever bytes
// happen to be tagged on a user's local file — so the library
// lookup was removed. The user's own library views (cover grid,
// album page) still display embedded art via a separate code path
// that reads cover_art.file_path directly, which is fine because
// that's their library, not Explore's view of MB.
type CoverArtProxy struct {
db *database.DB
cacheDir string
client *http.Client
limiter *RateLimiter
mu sync.Mutex // serializes disk writes
libOnce sync.Once
libIndex map[string]string // "album\x00artist" → cover art file path
mu sync.Mutex // serializes disk writes
}
// NewCoverArtProxy creates a proxy that checks the local library
// first and caches CAA thumbnails under the user data directory.
func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
// NewCoverArtProxy creates a proxy that caches CAA thumbnails
// under the user data directory. The db parameter is accepted
// for API stability but is no longer read; future cover-art
// logic that needs DB access can wire it back up.
func NewCoverArtProxy(_ *database.DB, limiter *RateLimiter) *CoverArtProxy {
dir := ""
dataDir, err := system.GetUserDataDirPath()
@@ -61,18 +70,18 @@ func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
}
return &CoverArtProxy{
db: db,
cacheDir: dir,
client: &http.Client{Timeout: thumbnailTimeout},
limiter: limiter,
}
}
// GetThumbnail returns a base64-encoded JPEG data URL for the given
// release group. Checks local library art first (by name match),
// 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.
// GetThumbnail returns a base64-encoded JPEG data URL for the
// given release group. Checks the disk cache first; falls back
// to a CAA network fetch. Returns "" on failure. The albumName
// / artistName args are accepted for API stability and ignored
// (they used to drive a library-by-name lookup; see the proxy
// type comment for why that was removed).
//
// The mbid argument MUST be a release group MBID. Track-level cover
// art (where you only have a release MBID) should be resolved by
@@ -80,7 +89,6 @@ func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
func (p *CoverArtProxy) GetThumbnail(
releaseGroupMBID, albumName, artistName string,
) string {
// Source 1+2: local library art + disk cache (instant).
if cached := p.GetThumbnailCached(releaseGroupMBID, albumName, artistName); cached != "" {
return cached
}
@@ -106,67 +114,70 @@ 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.
// GetThumbnailCached returns the disk-cached cover art for the
// release group, or "" if it isn't on disk. Does NOT fetch from
// the network. albumName/artistName accepted for API stability
// and ignored — see the proxy type comment.
func (p *CoverArtProxy) GetThumbnailCached(
releaseGroupMBID, albumName, artistName string,
releaseGroupMBID, _, _ 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).
// 1. Disk cache for the release group MBID (shared with discography).
// 2. Disk cache for the release MBID (per-track fallback).
// 3. CAA network fetch on the release group (populates RG cache).
// 4. 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).
//
// The albumName/artistName args are accepted for API stability and
// ignored — see the proxy type comment for why the library-by-name
// step was removed.
func (p *CoverArtProxy) GetTrackThumbnail(
releaseMBID, releaseGroupMBID, albumName, artistName string,
releaseMBID, releaseGroupMBID, _, _ string,
) string {
// Source 1: local library art (instant).
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
}
return p.GetCandidateThumbnail(releaseMBID, releaseGroupMBID)
}
// GetCandidateThumbnail is the canonical CAA-only lookup: disk
// cache for the release group, disk cache for the release, then
// CAA network on each in turn. Used everywhere the user is
// browsing or reviewing albums that aren't *their* library copy
// (autotag review, explore) — for those views we want the
// canonical CAA art, not whatever ID3 bytes happen to be tagged
// on a local file. Returns "" when no art is available.
func (p *CoverArtProxy) GetCandidateThumbnail(
releaseMBID, releaseGroupMBID string,
) string {
if p.cacheDir == "" {
return ""
}
// Source 2: disk cache for release group (shared with discography).
// 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).
// 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.
// Network fetch on release group.
if releaseGroupMBID != "" {
url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url)
@@ -178,13 +189,11 @@ func (p *CoverArtProxy) GetTrackThumbnail(
}
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).
// Network fetch on release (fallback).
if releaseMBID != "" {
url := CoverArtURL(releaseMBID)
data, cacheable, err := p.fetch(url)
@@ -205,18 +214,14 @@ func (p *CoverArtProxy) GetTrackThumbnail(
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.
// GetTrackThumbnailCached returns the disk-cached track thumbnail
// (RG MBID first, then release MBID). Returns "" when nothing is
// cached. Does NOT fetch from the network. albumName/artistName
// accepted for API stability and ignored — see the proxy type
// comment.
func (p *CoverArtProxy) GetTrackThumbnailCached(
releaseMBID, releaseGroupMBID, albumName, artistName string,
releaseMBID, releaseGroupMBID, _, _ string,
) string {
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
}
if p.cacheDir == "" {
return ""
}
@@ -237,72 +242,7 @@ func (p *CoverArtProxy) GetTrackThumbnailCached(
}
// ---------------------------------------------------------------------------
// Source 1: local library art
// ---------------------------------------------------------------------------
// libraryArt returns a base64 data URL for the album if it exists
// in the local music library. Matched by lowercased album name +
// artist name.
func (p *CoverArtProxy) libraryArt(albumName, artistName string) string {
p.libOnce.Do(p.buildLibraryIndex)
key := libraryArtKey(albumName, artistName)
path, ok := p.libIndex[key]
if !ok || path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return ""
}
mime := "image/jpeg"
if strings.HasSuffix(strings.ToLower(path), ".png") {
mime = "image/png"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
}
func (p *CoverArtProxy) buildLibraryIndex() {
p.libIndex = make(map[string]string)
if p.db == nil {
return
}
rows, err := p.db.QueryContext(`
SELECT rg.name, a.name, ca.file_path
FROM release_groups rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
LEFT JOIN cover_art ca ON ca.id = rg.cover_art_id
WHERE ca.file_path IS NOT NULL AND ca.file_path != ''
`)
if err != nil {
return
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var album, artist, path string
if err := rows.Scan(&album, &artist, &path); err == nil {
key := libraryArtKey(album, artist)
p.libIndex[key] = path
}
}
}
func libraryArtKey(album, artist string) string {
return strings.ToLower(album) + "\x00" + strings.ToLower(artist)
}
// ---------------------------------------------------------------------------
// Source 2+3: CAA disk cache and network fetch
// CAA disk cache and network fetch
// ---------------------------------------------------------------------------
func (p *CoverArtProxy) fetch(url string) ([]byte, bool, error) {
+245 -85
View File
@@ -18,16 +18,17 @@ import (
// response cache. Its exported methods form the binding surface
// that the frontend calls via generated TypeScript stubs.
type Service struct {
mb *MusicBrainzClient
lb *ListenBrainzClient
cache *Cache
index *SearchIndex
artProxy *CoverArtProxy
artistImg *ArtistImageProvider
libMBID *LibraryMBIDIndex
db *database.DB
logger *slog.Logger
ctx context.Context
mb *MusicBrainzClient
lb *ListenBrainzClient
cache *Cache
index *SearchIndex
artProxy *CoverArtProxy
artistImg *ArtistImageProvider
libMBID *LibraryMBIDIndex
caaLimiter *RateLimiter
db *database.DB
logger *slog.Logger
ctx context.Context
}
// NewExploreService creates a Service backed by the given
@@ -57,24 +58,38 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
)
index := NewSearchIndex(db, lb, artistImg, logger.WithGroup("search-index"))
index.MarkReadyIfPopulated() // make index queryable immediately if data exists
libMBID := NewLibraryMBIDIndex(db)
logger.Info("explore service created")
return &Service{
mb: mb,
lb: lb,
cache: cache,
index: index,
artProxy: artProxy,
artistImg: artistImg,
libMBID: libMBID,
db: db,
logger: logger,
ctx: context.Background(),
mb: mb,
lb: lb,
cache: cache,
index: index,
artProxy: artProxy,
artistImg: artistImg,
libMBID: libMBID,
caaLimiter: caaLimiter,
db: db,
logger: logger,
ctx: context.Background(),
}
}
// MusicBrainz returns the shared cached MB client so other services
// (e.g. autotag) can reuse it without spinning up a second limiter.
func (e *Service) MusicBrainz() *MusicBrainzClient {
return e.mb
}
// CAALimiter returns the shared Cover Art Archive rate limiter.
// Consumers must respect it for any fresh CAA HTTP GETs.
func (e *Service) CAALimiter() *RateLimiter {
return e.caaLimiter
}
// SetContext injects the Wails runtime context. Called from
// OnStartup after the Wails runtime is initialised.
func (e *Service) SetContext(ctx context.Context) {
@@ -137,18 +152,21 @@ func (e *Service) InvalidateIndexDiscographies() {
// SearchArtists queries MusicBrainz for artists matching the query.
func (e *Service) SearchArtists(query string) ([]MBArtist, error) {
artists, _, err := e.mb.SearchArtists(e.ctx, query, mbSearchLimit)
return artists, err
}
// SearchReleaseGroups queries MusicBrainz for release groups matching the query.
func (e *Service) SearchReleaseGroups(query string) ([]MBReleaseGroup, error) {
rgs, _, err := e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit)
return rgs, err
}
// SearchRecordings queries MusicBrainz for recordings matching the query.
func (e *Service) SearchRecordings(query string) ([]MBRecording, error) {
recs, _, err := e.mb.SearchRecordings(e.ctx, query, mbSearchLimit)
return recs, err
}
@@ -173,6 +191,7 @@ func (e *Service) SearchLocal(query string) *MBSearchResult {
filtered = append(filtered, a)
}
}
result.Artists = filtered
}
@@ -333,10 +352,10 @@ func (e *Service) BrowseReleaseGroups(artistMBID string) ([]MBReleaseGroup, erro
// of release groups returned from MB browse-by-artist. MB browse
// doesn't echo back the artist credit on each item (since the artist
// is the query parameter), so we need to find a name from somewhere:
// 1. First non-empty ArtistCredit on any release group
// 2. The local explore_index (if the artist was previously indexed)
// 3. A LookupArtist call to MB (last resort)
// 4. The MBID itself (worst case fallback)
// 1. First non-empty ArtistCredit on any release group
// 2. The local explore_index (if the artist was previously indexed)
// 3. A LookupArtist call to MB (last resort)
// 4. The MBID itself (worst case fallback)
func (e *Service) resolveArtistName(artistMBID string, rgs []MBReleaseGroup) string {
// Try first non-empty ArtistCredit from the release groups.
for _, rg := range rgs {
@@ -346,12 +365,19 @@ func (e *Service) resolveArtistName(artistMBID string, rgs []MBReleaseGroup) str
}
// Check the index for a previously-indexed artist row.
if indexed := e.index.LookupArtistByMBID(artistMBID); indexed != nil && indexed.Title != "" && indexed.Title != artistMBID {
if indexed := e.index.LookupArtistByMBID(
artistMBID,
); indexed != nil && indexed.Title != "" &&
indexed.Title != artistMBID {
return indexed.Title
}
// Last resort: hit MB lookup.
if artist, err := e.mb.LookupArtist(e.ctx, artistMBID); err == nil && artist != nil && artist.Name != "" {
if artist, err := e.mb.LookupArtist(
e.ctx,
artistMBID,
); err == nil && artist != nil &&
artist.Name != "" {
return artist.Name
}
@@ -370,6 +396,7 @@ func (e *Service) BrowseReleases(releaseGroupMBID string) ([]MBRelease, error) {
// InLibrary flag on each track so the tracklist renderer can
// show the library-status indicator without a per-track roundtrip.
var trackMBIDs []string
for _, rel := range releases {
for _, t := range rel.Tracks {
if t.MBID != "" {
@@ -572,10 +599,20 @@ func (e *Service) GetThumbnail(releaseGroupMBID, albumName, artistName string) s
// discography cache; falls back to the release-level CAA endpoint
// when the RG isn't known — useful when the track's preferred CAA
// release doesn't belong to any RG currently in the index.
func (e *Service) GetTrackThumbnail(releaseMBID, releaseGroupMBID, albumName, artistName string) string {
func (e *Service) GetTrackThumbnail(
releaseMBID, releaseGroupMBID, albumName, artistName string,
) string {
return e.artProxy.GetTrackThumbnail(releaseMBID, releaseGroupMBID, albumName, artistName)
}
// GetCandidateThumbnail returns CAA-only cover art for an autotag
// candidate, skipping the library-by-name index so embedded ID3
// art on the user's existing files doesn't pollute the candidate
// preview. Disk cache → network on RG → network on release.
func (e *Service) GetCandidateThumbnail(releaseMBID, releaseGroupMBID string) string {
return e.artProxy.GetCandidateThumbnail(releaseMBID, releaseGroupMBID)
}
// TrackThumbnailRequest is a single item in a batch track thumbnail
// request. Either ReleaseMBID or ReleaseGroupMBID may be empty;
// the proxy tries whichever is present.
@@ -663,6 +700,7 @@ func (e *Service) GetArtistImageCached(artistMBID string) string {
// image is cached.
func (e *Service) GetArtistImageCachedPath(artistMBID string) string {
_, medium, _, _ := e.artistImg.GetImageURLs(artistMBID)
return medium
}
@@ -703,7 +741,10 @@ func (e *Service) GetPopularityBatch(mbids []string) map[string]PersonalizationR
// Include entries that have library/similar flags but no popularity.
for mbid := range batch.InLibrary {
if _, ok := out[mbid]; !ok {
out[mbid] = PersonalizationResult{InLibrary: true, SimilarityScore: batch.SimilarityScores[mbid]}
out[mbid] = PersonalizationResult{
InLibrary: true,
SimilarityScore: batch.SimilarityScores[mbid],
}
}
}
@@ -928,13 +969,21 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
p1Dur := time.Since(p1Start)
e.logger.Info("search phase 1 complete (MB)",
"query", query,
"artists", len(result.Artists),
"releaseGroups", len(result.ReleaseGroups),
"recordings", len(result.Recordings),
"expanded", len(result.Artists) > mbSearchLimit || len(result.ReleaseGroups) > mbSearchLimit || len(result.Recordings) > mbSearchLimit,
"elapsed", p1Dur.Round(time.Millisecond),
e.logger.Info(
"search phase 1 complete (MB)",
"query",
query,
"artists",
len(result.Artists),
"releaseGroups",
len(result.ReleaseGroups),
"recordings",
len(result.Recordings),
"expanded",
len(result.Artists) > mbSearchLimit || len(result.ReleaseGroups) > mbSearchLimit ||
len(result.Recordings) > mbSearchLimit,
"elapsed",
p1Dur.Round(time.Millisecond),
)
// Phases 2+3: when the index is ready, use cached popularity
@@ -975,6 +1024,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// ordering for result sets where MB gave every candidate
// the same text relevance score.
var missingPop []string
for _, mbid := range artistMBIDs {
if artistPop[mbid] <= 0 {
missingPop = append(missingPop, mbid)
@@ -985,6 +1035,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
popCtx, popCancel := context.WithTimeout(e.ctx, searchSlowPathTimeout)
pop, err := e.lb.ArtistPopularity(popCtx, missingPop)
popCancel()
if err == nil && pop != nil {
@@ -1002,6 +1053,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// so a hung LB server doesn't stall the search.
popCtx, popCancel := context.WithTimeout(e.ctx, 2*time.Second)
pop, _ := e.lb.ArtistPopularity(popCtx, artistMBIDs)
popCancel()
if pop != nil {
@@ -1045,12 +1097,14 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// Leg 1: LB popularity for RGs and recordings.
go func() {
defer wgSlow.Done()
e.boostWithPopularityRGsAndRecs(&result)
}()
// Leg 2: cross-reference artist discographies.
go func() {
defer wgSlow.Done()
if slowCtx.Err() == nil {
e.crossReferenceAlbums(slowCtx, query, &result)
}
@@ -1307,9 +1361,11 @@ func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) {
}
// Collect new entries from index that MB didn't return.
var newArtists []MBArtist
var newRGs []MBReleaseGroup
var newRecs []MBRecording
var (
newArtists []MBArtist
newRGs []MBReleaseGroup
newRecs []MBRecording
)
for _, h := range hits {
switch h.EntityType {
@@ -1318,18 +1374,18 @@ func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) {
score := int(float64(scalePopularity(h.Popularity)) * 0.5)
newArtists = append(newArtists, MBArtist{
MBID: h.MBID,
Name: h.Title,
Type: h.ArtistType,
Country: h.Country,
MBID: h.MBID,
Name: h.Title,
Type: h.ArtistType,
Country: h.Country,
Disambiguation: h.Disambiguation,
SortName: h.SortName,
Score: score,
HasPopularity: h.Popularity > 0,
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
InLibrary: h.InLibrary || h.LocalArtistID > 0,
LocalID: h.LocalArtistID,
SortName: h.SortName,
Score: score,
HasPopularity: h.Popularity > 0,
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
InLibrary: h.InLibrary || h.LocalArtistID > 0,
LocalID: h.LocalArtistID,
})
artistMBIDs[h.MBID] = true
@@ -1446,7 +1502,8 @@ func filterAndCap(result *MBSearchResult) {
// Drop very-low-popularity results when the result
// set contains meaningfully popular alternatives.
if maxPop >= minPopularityFloor && a.HasPopularity && a.Popularity < minPopularityFloor {
if maxPop >= minPopularityFloor && a.HasPopularity &&
a.Popularity < minPopularityFloor {
continue
}
@@ -1507,7 +1564,7 @@ const (
mbSearchLimit = 25
// mbSearchMaxLimit caps the expanded fetch. MB's API maximum is 100.
mbSearchMaxLimit = 75
mbSearchMaxLimit = 75 //nolint:unused // referenced by deferred MB search rework
// indexSearchLimit is the number of results to fetch from the local
// popularity index (Phase 0). Larger than maxResults because
@@ -1600,6 +1657,8 @@ var mbSpecialPurposeArtists = map[string]bool{
// popularity data from the local search index. No API calls —
// just SQLite lookups. This is the fast path used when the index
// is ready.
//
//nolint:unused // referenced by deferred MB search rework.
func (e *Service) boostWithIndexPopularity(result *MBSearchResult) {
// Collect all MBIDs across all entity types.
allMBIDs := make([]string, 0,
@@ -1657,7 +1716,12 @@ func (e *Service) boostWithIndexPopularity(result *MBSearchResult) {
}
}
rerankReleaseGroupsPersonalized(result.ReleaseGroups, rgPop, batch.InLibrary, batch.SimilarityScores)
rerankReleaseGroupsPersonalized(
result.ReleaseGroups,
rgPop,
batch.InLibrary,
batch.SimilarityScores,
)
recPop := make(map[string]int, len(result.Recordings))
for i, r := range result.Recordings {
@@ -1713,20 +1777,24 @@ func (e *Service) boostWithIndexPopularityRGsAndRecs(result *MBSearchResult) {
// common path cache-only while correctness-critical cases get
// a ~1 round-trip to LB.
missingRecs := make([]string, 0)
for _, r := range result.Recordings {
if r.MBID == "" {
continue
}
if _, ok := batch.Popularity[r.MBID]; !ok {
missingRecs = append(missingRecs, r.MBID)
}
}
missingRGs := make([]string, 0)
for _, rg := range result.ReleaseGroups {
if rg.MBID == "" {
continue
}
if _, ok := batch.Popularity[rg.MBID]; !ok {
missingRGs = append(missingRGs, rg.MBID)
}
@@ -1936,7 +2004,9 @@ func (e *Service) boostNameMatches(query string, result *MBSearchResult) {
if len(result.Artists) > 1 {
for i := range result.Artists {
tier := nameMatchTier(q, strings.ToLower(result.Artists[i].Name))
result.Artists[i].Score = int(float64(result.Artists[i].Score) * (1.0 + tierBonus[tier]))
result.Artists[i].Score = int(
float64(result.Artists[i].Score) * (1.0 + tierBonus[tier]),
)
}
sort.SliceStable(result.Artists, func(i, j int) bool {
@@ -1954,7 +2024,9 @@ func (e *Service) boostNameMatches(query string, result *MBSearchResult) {
tier := rgMatchTier(q,
strings.ToLower(result.ReleaseGroups[i].Title),
strings.ToLower(result.ReleaseGroups[i].ArtistCredit))
result.ReleaseGroups[i].Score = int(float64(result.ReleaseGroups[i].Score) * (1.0 + rgTierBonus[tier]))
result.ReleaseGroups[i].Score = int(
float64(result.ReleaseGroups[i].Score) * (1.0 + rgTierBonus[tier]),
)
}
sort.SliceStable(result.ReleaseGroups, func(i, j int) bool {
@@ -2065,11 +2137,18 @@ func rgMatchTier(query, title, artistCredit string) int {
// rerankArtists sorts artists by blended score and updates their
// Score field to the new value (0100 scale).
//
//nolint:unused // referenced by deferred MB search rework.
func rerankArtists(artists []MBArtist, pop map[string]int, libraryMBIDs map[string]bool) {
rerankArtistsPersonalized(artists, pop, libraryMBIDs, nil)
}
func rerankArtistsPersonalized(artists []MBArtist, pop map[string]int, inLib map[string]bool, simScores map[string]int) {
func rerankArtistsPersonalized(
artists []MBArtist,
pop map[string]int,
inLib map[string]bool,
simScores map[string]int,
) {
if len(artists) == 0 {
return
}
@@ -2078,13 +2157,29 @@ func rerankArtistsPersonalized(artists []MBArtist, pop map[string]int, inLib map
maxSim := maxSimScoreVal(simScores)
sort.SliceStable(artists, func(i, j int) bool {
si := blendedScoreFull(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop, personalScore(artists[i].MBID, inLib, simScores, maxSim))
sj := blendedScoreFull(float64(artists[j].Score)/100.0, pop[artists[j].MBID], maxPop, personalScore(artists[j].MBID, inLib, simScores, maxSim))
si := blendedScoreFull(
float64(artists[i].Score)/100.0,
pop[artists[i].MBID],
maxPop,
personalScore(artists[i].MBID, inLib, simScores, maxSim),
)
sj := blendedScoreFull(
float64(artists[j].Score)/100.0,
pop[artists[j].MBID],
maxPop,
personalScore(artists[j].MBID, inLib, simScores, maxSim),
)
return si > sj
})
for i := range artists {
s := blendedScoreFull(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop, personalScore(artists[i].MBID, inLib, simScores, maxSim))
s := blendedScoreFull(
float64(artists[i].Score)/100.0,
pop[artists[i].MBID],
maxPop,
personalScore(artists[i].MBID, inLib, simScores, maxSim),
)
artists[i].Score = int(s * 100)
}
}
@@ -2095,7 +2190,12 @@ func rerankRecordings(recordings []MBRecording, pop map[string]int) {
rerankRecordingsPersonalized(recordings, pop, nil, nil)
}
func rerankRecordingsPersonalized(recordings []MBRecording, pop map[string]int, inLib map[string]bool, simScores map[string]int) {
func rerankRecordingsPersonalized(
recordings []MBRecording,
pop map[string]int,
inLib map[string]bool,
simScores map[string]int,
) {
if len(recordings) == 0 {
return
}
@@ -2104,13 +2204,29 @@ func rerankRecordingsPersonalized(recordings []MBRecording, pop map[string]int,
maxSim := maxSimScoreVal(simScores)
sort.SliceStable(recordings, func(i, j int) bool {
si := blendedScoreFull(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop, personalScore(recordings[i].MBID, inLib, simScores, maxSim))
sj := blendedScoreFull(float64(recordings[j].Score)/100.0, pop[recordings[j].MBID], maxPop, personalScore(recordings[j].MBID, inLib, simScores, maxSim))
si := blendedScoreFull(
float64(recordings[i].Score)/100.0,
pop[recordings[i].MBID],
maxPop,
personalScore(recordings[i].MBID, inLib, simScores, maxSim),
)
sj := blendedScoreFull(
float64(recordings[j].Score)/100.0,
pop[recordings[j].MBID],
maxPop,
personalScore(recordings[j].MBID, inLib, simScores, maxSim),
)
return si > sj
})
for i := range recordings {
s := blendedScoreFull(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop, personalScore(recordings[i].MBID, inLib, simScores, maxSim))
s := blendedScoreFull(
float64(recordings[i].Score)/100.0,
pop[recordings[i].MBID],
maxPop,
personalScore(recordings[i].MBID, inLib, simScores, maxSim),
)
recordings[i].Score = int(s * 100)
}
}
@@ -2121,7 +2237,12 @@ func rerankReleaseGroups(rgs []MBReleaseGroup, pop map[string]int) {
rerankReleaseGroupsPersonalized(rgs, pop, nil, nil)
}
func rerankReleaseGroupsPersonalized(rgs []MBReleaseGroup, pop map[string]int, inLib map[string]bool, simScores map[string]int) {
func rerankReleaseGroupsPersonalized(
rgs []MBReleaseGroup,
pop map[string]int,
inLib map[string]bool,
simScores map[string]int,
) {
if len(rgs) == 0 {
return
}
@@ -2130,13 +2251,29 @@ func rerankReleaseGroupsPersonalized(rgs []MBReleaseGroup, pop map[string]int, i
maxSim := maxSimScoreVal(simScores)
sort.SliceStable(rgs, func(i, j int) bool {
si := blendedScoreFull(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop, personalScore(rgs[i].MBID, inLib, simScores, maxSim))
sj := blendedScoreFull(float64(rgs[j].Score)/100.0, pop[rgs[j].MBID], maxPop, personalScore(rgs[j].MBID, inLib, simScores, maxSim))
si := blendedScoreFull(
float64(rgs[i].Score)/100.0,
pop[rgs[i].MBID],
maxPop,
personalScore(rgs[i].MBID, inLib, simScores, maxSim),
)
sj := blendedScoreFull(
float64(rgs[j].Score)/100.0,
pop[rgs[j].MBID],
maxPop,
personalScore(rgs[j].MBID, inLib, simScores, maxSim),
)
return si > sj
})
for i := range rgs {
s := blendedScoreFull(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop, personalScore(rgs[i].MBID, inLib, simScores, maxSim))
s := blendedScoreFull(
float64(rgs[i].Score)/100.0,
pop[rgs[i].MBID],
maxPop,
personalScore(rgs[i].MBID, inLib, simScores, maxSim),
)
rgs[i].Score = int(s * 100)
}
}
@@ -2156,7 +2293,12 @@ func maxSimScoreVal(scores map[string]int) int {
// personalScore returns the personalization signal (0.01.0) for an MBID.
// Uses similarity scores from similar_artist_map, scaled by the max score
// in the batch so the most similar artist gets the full personalSimilar weight.
func personalScore(mbid string, inLib map[string]bool, simScores map[string]int, maxSimScore int) float64 {
func personalScore(
mbid string,
inLib map[string]bool,
simScores map[string]int,
maxSimScore int,
) float64 {
if inLib[mbid] {
return personalInLibrary
}
@@ -2168,7 +2310,6 @@ func personalScore(mbid string, inLib map[string]bool, simScores map[string]int,
return 0.0
}
// ---------------------------------------------------------------------------
// Top Results — intent-scored cards
// ---------------------------------------------------------------------------
@@ -2268,6 +2409,7 @@ func (e *Service) resolveTopResults(query string, result *MBSearchResult) []TopR
// Stage 1: gather candidates.
clicks := e.getSearchClicks(q)
exactMatches := e.index.ExactMatches(q, topResultsExactCap)
candidates := e.gatherTopCandidates(q, result, exactMatches, clicks)
if len(candidates) == 0 {
return nil
@@ -2282,6 +2424,7 @@ func (e *Service) resolveTopResults(query string, result *MBSearchResult) []TopR
// matches (query contains both the title and the artist of a
// recording or album) are treated the same way.
var exactCandidates []topCandidate
for _, c := range candidates {
isExact := isExactNameMatch(q, c.topResult.Name) ||
isExactNameMatch(q, c.topResult.ArtistCredit) ||
@@ -2457,7 +2600,7 @@ func (e *Service) gatherTopCandidates(
limit = len(result.Artists)
}
for i := 0; i < limit; i++ {
for i := range limit {
a := result.Artists[i]
quality := e.scoreArtistCandidate(q, &a, clicks)
@@ -2512,10 +2655,11 @@ func (e *Service) gatherTopCandidates(
limit = len(result.ReleaseGroups)
}
for i := 0; i < limit; i++ {
for i := range limit {
rg := result.ReleaseGroups[i]
quality := e.scoreReleaseGroupCandidate(q, &rg, clicks)
year := ""
if len(rg.FirstReleaseDate) >= 4 { //nolint:mnd
year = rg.FirstReleaseDate[:4]
@@ -2586,7 +2730,7 @@ func (e *Service) gatherTopCandidates(
limit = len(result.Recordings)
}
for i := 0; i < limit; i++ {
for i := range limit {
r := result.Recordings[i]
quality := e.scoreRecordingCandidate(q, &r, clicks)
@@ -2857,11 +3001,12 @@ func (e *Service) scoreExactMatch(
score := 0.0
if title == q {
switch {
case title == q:
score += fwExactTitle
} else if credit == q {
case credit == q:
score += fwExactArtist
} else {
default:
// Shouldn't happen — ExactMatches only returns rows whose
// title or artist matches. Defensive fallback.
score += fwContainsWord
@@ -2918,8 +3063,8 @@ func (e *Service) computeIntentPrior(
switch {
case wordCount == 1:
weights.artist *= 2.0 //nolint:mnd
weights.album *= 0.7 //nolint:mnd
weights.artist *= 2.0 //nolint:mnd
weights.album *= 0.7 //nolint:mnd
weights.recording *= 0.7 //nolint:mnd
case wordCount >= 4: //nolint:mnd
weights.artist *= 0.5 //nolint:mnd
@@ -2958,6 +3103,7 @@ func (e *Service) computeIntentPrior(
// index-sourced exact matches.
for _, c := range exactCandidates {
var listeners int
switch c.category {
case "artist":
listeners = artistListenerByMBID(result.Artists, c.topResult.MBID)
@@ -2982,6 +3128,7 @@ func (e *Service) computeIntentPrior(
// Signal: many recordings in the result list with the same
// title as the query → cover-wave pattern → strong recording.
titleMatches := 0
for _, r := range result.Recordings {
if isExactNameMatch(q, r.Title) {
titleMatches++
@@ -3000,17 +3147,17 @@ func (e *Service) computeIntentPrior(
// have higher listen counts than albums (each play increments
// the recording, not the album), so we use *listener* count
// rather than *listen* count to dampen that bias.
artistListeners := sumTopListeners(artistListenerCounts(result.Artists), 5) //nolint:mnd
artistListeners := sumTopListeners(artistListenerCounts(result.Artists), 5) //nolint:mnd
albumListeners := sumTopListeners(rgListenerCounts(result.ReleaseGroups), 5) //nolint:mnd
recListeners := sumTopListeners(recListenerCounts(result.Recordings), 5) //nolint:mnd
recListeners := sumTopListeners(recListenerCounts(result.Recordings), 5) //nolint:mnd
totalListeners := artistListeners + albumListeners + recListeners
if totalListeners > 0 {
// Apply as a 0.5x nudge so it doesn't override stronger
// signals. We'd rather trust shape and exact matches
// than raw listener distributions.
weights.artist *= 1.0 + 0.5*float64(artistListeners)/float64(totalListeners) //nolint:mnd
weights.album *= 1.0 + 0.5*float64(albumListeners)/float64(totalListeners) //nolint:mnd
weights.artist *= 1.0 + 0.5*float64(artistListeners)/float64(totalListeners) //nolint:mnd
weights.album *= 1.0 + 0.5*float64(albumListeners)/float64(totalListeners) //nolint:mnd
weights.recording *= 1.0 + 0.5*float64(recListeners)/float64(totalListeners) //nolint:mnd
}
@@ -3165,12 +3312,13 @@ func sumTopListeners(xs []int, n int) int {
}
sum := 0
for i := 0; i < n; i++ {
for i := range n {
sum += sorted[i]
}
return sum
}
// containsWord checks if text contains word as a whole word bounded
// by spaces, hyphens, or string boundaries.
func containsWord(text, word string) bool {
@@ -3260,10 +3408,12 @@ func normalizeForMatch(s string) string {
r >= '0' && r <= '9',
r >= 0x80: // keep non-ASCII as-is
b.WriteRune(r)
prevSpace = false
case r == ' ' || r == '\t':
if !prevSpace && b.Len() > 0 {
b.WriteByte(' ')
prevSpace = true
}
default:
@@ -3301,9 +3451,11 @@ func (e *Service) getSearchClicks(query string) map[string]searchClick {
result := make(map[string]searchClick)
for rows.Next() {
var mbid string
var count int
var lastClicked time.Time
var (
mbid string
count int
lastClicked time.Time
)
if err := rows.Scan(&mbid, &count, &lastClicked); err == nil {
result[mbid] = searchClick{count: count, lastClicked: lastClicked}
@@ -3334,13 +3486,19 @@ func (e *Service) RecordSearchClick(query, mbid, entityType string) {
// blendedScore computes relevanceWeight*relevance + popularityWeight*logPop.
// relevance is 01. listenCount is raw; maxListenCount is the
// maximum in the result set (for normalization).
//
//nolint:unused // referenced by deferred MB search rework.
func blendedScore(relevance float64, listenCount, maxListenCount int) float64 {
return blendedScoreFull(relevance, listenCount, maxListenCount, 0.0)
}
// blendedScoreFull computes the weighted blend of relevance, popularity,
// and personalization. personalization is 0.01.0.
func blendedScoreFull(relevance float64, listenCount, maxListenCount int, personalization float64) float64 {
func blendedScoreFull(
relevance float64,
listenCount, maxListenCount int,
personalization float64,
) float64 {
effectiveMax := maxListenCount
if effectiveMax < 100_000 { //nolint:mnd
effectiveMax = 100_000
@@ -3356,6 +3514,8 @@ func blendedScoreFull(relevance float64, listenCount, maxListenCount int, person
// and at most mbSearchMaxLimit. Aims for ~15% of total matches so
// the ranking pipeline has enough candidates to surface popular
// results that MB's text relevance alone would bury.
//
//nolint:unused // referenced by deferred MB search rework.
func dynamicSearchLimit(totalMatches int) int {
if totalMatches <= mbSearchLimit {
return mbSearchLimit
+1
View File
@@ -131,6 +131,7 @@ func (idx *LibraryMBIDIndex) AllArtistMBIDs() map[string]string {
return result
}
//nolint:unused // utility kept for future per-MBID existence checks.
func (idx *LibraryMBIDIndex) exists(table, mbid string) bool {
//nolint:gosec // table name is hardcoded from internal callers only
rows, err := idx.db.QueryContext(
+8 -6
View File
@@ -196,9 +196,11 @@ func (c *ListenBrainzClient) SimilarArtists(
if a.Score > b.Score {
return -1
}
if a.Score < b.Score {
return 1
}
return 0
})
@@ -320,12 +322,12 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity(
// /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
MBID string
Name string
Type string // "Group", "Person", etc
Country string // from "area" field
BeginYear int
EndYear int
WikidataQID string // extracted from rels
}
+50 -6
View File
@@ -39,7 +39,11 @@ type MusicBrainzClient struct {
// responses in the given Cache. The provided rate limiter is shared
// with all other MB consumers (e.g. artist image resolution) to
// prevent concurrent bursts from triggering 429s.
func NewMusicBrainzClient(cache *Cache, limiter *RateLimiter, logger *slog.Logger) *MusicBrainzClient {
func NewMusicBrainzClient(
cache *Cache,
limiter *RateLimiter,
logger *slog.Logger,
) *MusicBrainzClient {
mb := musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
Name: "YellowJacket",
Version: "dev",
@@ -317,6 +321,45 @@ func (c *MusicBrainzClient) BrowseReleaseGroups(
return out, nil
}
// LookupRelease fetches a single release by MBID (with media +
// recordings). Used by the autotag paste-URL escape hatch.
// Cached for 7 days.
func (c *MusicBrainzClient) LookupRelease(
ctx context.Context, mbid string,
) (*MBRelease, error) {
cacheKey := "mb:lookup:release:" + mbid
if data, ok := c.cache.Get(cacheKey); ok {
var out MBRelease
if err := json.Unmarshal(data, &out); err == nil {
return &out, nil
}
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
}
c.logger.Info("musicbrainz lookup release", "mbid", mbid)
r, err := c.mb.LookupRelease(
ctx,
mbtypes.MBID(mbid),
musicbrainzws2.IncludesFilter{
Includes: []string{"recordings", "media", "artist-credits", "release-groups"},
},
)
if err != nil {
return nil, err
}
out := convertRelease(r)
c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "release")
return &out, nil
}
// BrowseReleases fetches the releases for a given release group
// MBID, including media/track information. Cached for 7 days.
func (c *MusicBrainzClient) BrowseReleases(
@@ -482,11 +525,12 @@ func convertReleaseGroups(rgs []musicbrainzws2.ReleaseGroup) []MBReleaseGroup {
func convertRelease(r musicbrainzws2.Release) MBRelease {
rel := MBRelease{
MBID: string(r.ID),
Title: r.Title,
Date: r.Date.String(),
Country: string(r.CountryCode),
Status: r.Status,
MBID: string(r.ID),
Title: r.Title,
Date: r.Date.String(),
Country: string(r.CountryCode),
Status: r.Status,
ArtistCredit: r.ArtistCredit.String(),
}
for _, m := range r.Media {
+53 -27
View File
@@ -12,10 +12,10 @@ import (
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// Index build parameters.
@@ -88,12 +88,12 @@ const (
// SearchIndexResult is a single hit from the local popularity index.
type SearchIndexResult struct {
EntityType string `json:"entityType"`
MBID string `json:"mbid"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
ArtistMBID string `json:"artistMbid"`
Aliases string `json:"aliases,omitempty"`
EntityType string `json:"entityType"`
MBID string `json:"mbid"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
ArtistMBID string `json:"artistMbid"`
Aliases string `json:"aliases,omitempty"`
// Popularity signals.
Popularity int `json:"popularity"`
@@ -158,10 +158,10 @@ type lbSitewideArtist struct {
// - Tier 4: similar artists to library artists (background, ~24min)
// - Tier 5: organic growth from user browsing (ongoing, free)
type SearchIndex struct {
db *database.DB
lb *ListenBrainzClient
artistImg *ArtistImageProvider
logger *slog.Logger
db *database.DB
lb *ListenBrainzClient
artistImg *ArtistImageProvider
logger *slog.Logger
runtimeCtx context.Context // Wails runtime context for event emission
cancel context.CancelFunc
@@ -441,8 +441,10 @@ func (si *SearchIndex) refreshStatusCounts() {
defer func() { _ = rows.Close() }()
for rows.Next() {
var et string
var count int
var (
et string
count int
)
if err := rows.Scan(&et, &count); err == nil {
switch et {
@@ -509,6 +511,8 @@ func (si *SearchIndex) setTierStatus(name, state string, total, completed int) {
}
// setTierError marks a tier as errored.
//
//nolint:unused // kept for future per-tier failure surfacing.
func (si *SearchIndex) setTierError(name, errMsg string) {
si.mu.Lock()
@@ -599,7 +603,10 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult
}
query := "SELECT mbid, popularity, listener_count, in_library FROM explore_index WHERE mbid IN (" +
strings.Join(placeholders, ",") + ")"
strings.Join(
placeholders,
",",
) + ")"
rows, err := si.db.QueryContext(query, args...)
if err != nil {
@@ -616,10 +623,12 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult
}
for rows.Next() {
var mbid string
var pop int
var listeners int
var inLib int
var (
mbid string
pop int
listeners int
inLib int
)
if err := rows.Scan(&mbid, &pop, &listeners, &inLib); err == nil {
existing, ok := result.Popularity[mbid]
@@ -707,7 +716,9 @@ func (si *SearchIndex) LookupArtistByMBID(mbid string) *SearchIndexResult {
// rows whose caa_release_mbid matches. Used to find parent release
// groups for tracks so we can fetch cover art via the existing
// release-group endpoint instead of the per-release endpoint.
func (si *SearchIndex) ReleaseGroupMBIDsForCAAReleaseMBIDs(caaReleaseMBIDs []string) map[string]string {
func (si *SearchIndex) ReleaseGroupMBIDsForCAAReleaseMBIDs(
caaReleaseMBIDs []string,
) map[string]string {
if len(caaReleaseMBIDs) == 0 {
return nil
}
@@ -925,8 +936,6 @@ func (si *SearchIndex) AddFromCache(artistName, artistMBID string, rgs []MBRelea
)
}
// Search queries the local FTS5 index and returns matches sorted
// by popularity descending.
// ExactMatches returns index rows whose normalized title (or artist
// name) exactly equals the given query. Used by the top-results
// intent pipeline as a dedicated retrieval source — exact matches
@@ -1017,6 +1026,7 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex
}
var out []SearchIndexResult
out = append(out, buckets["artist"]...)
out = append(out, buckets["release_group"]...)
out = append(out, buckets["recording"]...)
@@ -1024,7 +1034,11 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex
return out
}
func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult { if !si.IsReady() {
// Search queries the local FTS5 index and returns matches ordered
// by relevance (popularity-blended). Returns nil when the index
// hasn't finished its initial build.
func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult {
if !si.IsReady() {
return nil
}
@@ -1269,7 +1283,12 @@ func (si *SearchIndex) build(ctx context.Context) {
}
si.setMeta("tier2_built", time.Now().UTC().Format(time.RFC3339))
si.setTierStatus("Sitewide Discographies", "complete", len(newSitewide), len(newSitewide))
si.setTierStatus(
"Sitewide Discographies",
"complete",
len(newSitewide),
len(newSitewide),
)
si.refreshStatusCounts()
si.logger.Info("search index: Tier 2 complete (sitewide discographies)")
}
@@ -1324,6 +1343,7 @@ func (si *SearchIndex) build(ctx context.Context) {
} else {
si.setTierStatus("Popularity Backfill", "running", 0, 0)
si.buildTier5Popularity(ctx, indexLB)
if ctx.Err() != nil {
return
}
@@ -1984,6 +2004,7 @@ func (si *SearchIndex) prefetchArtistMetadata(
}
batch := mbids[i:end]
batchWG.Add(1)
go func(chunk []string) {
@@ -2931,7 +2952,10 @@ func (si *SearchIndex) GetSimilarityScores(mbids []string) map[string]int {
}
query := "SELECT similar_artist_mbid, MAX(score) FROM similar_artist_map WHERE similar_artist_mbid IN (" +
strings.Join(placeholders, ",") + ") GROUP BY similar_artist_mbid"
strings.Join(
placeholders,
",",
) + ") GROUP BY similar_artist_mbid"
rows, err := si.db.QueryContext(query, args...)
if err != nil {
@@ -2943,8 +2967,10 @@ func (si *SearchIndex) GetSimilarityScores(mbids []string) map[string]int {
result := make(map[string]int, len(mbids))
for rows.Next() {
var mbid string
var score int
var (
mbid string
score int
)
if err := rows.Scan(&mbid, &score); err == nil {
result[mbid] = score
+13 -12
View File
@@ -20,7 +20,7 @@ type MBSearchResult struct {
// categorized search lists. Computed by intent scoring after all
// reranking is complete.
type TopResult struct {
EntityType string `json:"entityType"` // "artist", "release_group", "recording"
EntityType string `json:"entityType"` // "artist", "release_group", "recording"
MBID string `json:"mbid"`
Name string `json:"name"`
ArtistCredit string `json:"artistCredit,omitempty"` // for tracks/albums
@@ -51,7 +51,7 @@ type MBArtist struct {
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
InLibrary bool `json:"inLibrary"` // true if the user owns music by this artist
LocalID int64 `json:"localId,omitempty"` // local artist row ID for navigation
}
@@ -64,21 +64,22 @@ 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
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
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
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.
type MBRelease struct {
MBID string `json:"mbid"`
Title string `json:"title"`
Date string `json:"date"`
Country string `json:"country"`
Status string `json:"status"`
Tracks []MBTrack `json:"tracks,omitempty"`
MBID string `json:"mbid"`
Title string `json:"title"`
Date string `json:"date"`
Country string `json:"country"`
Status string `json:"status"`
ArtistCredit string `json:"artistCredit,omitempty"`
Tracks []MBTrack `json:"tracks,omitempty"`
}
// MBRecording is a Wails-friendly projection of a MusicBrainz
@@ -91,7 +92,7 @@ type MBRecording struct {
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
InLibrary bool `json:"inLibrary"` // true if the user owns this recording
LocalID int64 `json:"localId,omitempty"` // local recording row ID
}