wip on autotagging
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 (0–100 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.0–1.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 0–1. 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.0–1.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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user