Files
yellowjacket/backend/explore/searchindex.go
T
logan de2b324e20
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
feat(explore): refuse 0.6 GB on someone's mobile data
Plan 016 B4. The catalog artifact is about 0.6 GB and the app fetched it
with no awareness of the connection: on a desktop that is a minute of
bandwidth, on a phone it can be a month's allowance. It is now skipped on
a cellular connection unless `AllowMeteredCatalogDownload` is on, with
the toggle in Settings' Search Index section, where the text explaining
what the catalog is already lives.

The file layout is dictated by the cgo rule rather than by taste.
`explore` is imported by `cmd/indexbuild`, which builds with
CGO_ENABLED=0 and must not link Wails, so `netpolicy.go` holds the policy
and the JSON parsing -- tested on every platform -- and the single
platform call is a closure injected from `app.go`, which already names
`application` legitimately.

Three rules in it are load-bearing. An unknown answer is not a metered
one: only mobile answers at all, and treating silence as metered would
have disabled the download for every desktop user in the world. Cellular
is the only signal available, because the runtime reports
`wifi|cellular|ethernet|none` and no metered flag -- so a metered Wi-Fi
cannot be detected and is not refused, which is documented rather than
implied. And the gate runs before the first status write, so declining is
a no-op instead of a job in the indicator and an error tier to dismiss.

Two corrections to the plan while implementing it: the portable API is
`application.Mobile.NetworkJSON()`, not `application.Android`'s, which
exists only under the `android` build tag; and the permission is read at
the moment a download would start, so enabling it takes effect on the
next attempt rather than the next launch.
2026-08-17 10:48:00 -04:00

2910 lines
84 KiB
Go

package explore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
// Index build parameters.
const (
// indexMaxRGs is the number of release groups fetched per
// artist by the API discography path (new library artists).
indexMaxRGs = 50
// indexMaxRecs is the number of recordings fetched per artist
// by the API discography path (new library artists).
indexMaxRecs = 200
// indexMinPopularity is the minimum listen count for an entry
// to be indexed. Cuts noise from long-tail entries.
indexMinPopularity = 50
// championPopThreshold is the popularity floor for a row to join
// the champion FTS. Rows below it (unless owned) can never win a
// generic short-prefix query, whose ranking is popularity-dominated,
// so excluding them is lossless for those queries. ~90k rows.
championPopThreshold = 10000
// genericMaxTokenLen classifies a query as "generic" when its
// longest token is at most this many runes. Such queries ("the",
// "a", "u2") match a huge slice of the index with no selective term,
// so they route to the champion tier; anything with a longer token
// is selective enough that the full index is already fast.
genericMaxTokenLen = 3
// Typo-tolerant rescue (see fuzzyRescue). When ordinary prefix-FTS
// retrieval returns fewer than fuzzyRescueThinHits rows — usually a
// misspelled query that shares no token prefix with any indexed name
// — a bigram-similarity pass over the champion set kicks in.
// fuzzyRescueMinLen guards against very short queries (too few
// bigrams to discriminate); fuzzyRescueMinScore is the minimum bigram
// Dice coefficient for a candidate to count as a plausible match.
fuzzyRescueThinHits = 3
fuzzyRescueMinLen = 4
fuzzyRescueMinScore = 0.34
// prefixCacheTTL bounds how long a memoised generic-query result is
// served before it is recomputed; prefixCacheMaxEntries caps the map.
prefixCacheTTL = 10 * time.Minute
prefixCacheMaxEntries = 512
// championBuiltKey marks in explore_index_meta that the champion FTS
// has been populated at least once, so it is trusted across restarts.
championBuiltKey = "champion_built"
// localXrefReadyKey marks that PopulateLocalCrossReferences has synced
// the current library into the index at least once. The unchanged-
// library launch path skips the (write-heavy) re-sync when it is set;
// a scan re-runs the sync unconditionally, and an explore_index wipe
// clears explore_index_meta with it.
localXrefReadyKey = "local_xref_ready"
// lyricsIndexReadyKey marks that the lyrics FTS has been rebuilt from
// the library at least once. It is kept in sync incrementally by the
// LRCLIB backfill thereafter, so the unchanged-library launch path
// skips the redundant full rebuild when it is set.
lyricsIndexReadyKey = "lyrics_index_ready"
// indexBatchSize is the number of rows per INSERT transaction.
indexBatchSize = 100
// indexerRate is the requests-per-second for the background
// indexer's dedicated rate limiter (LB allows 30/10s).
indexerRate = 3
// discogBackfillMaxPerRun bounds how many owned artists a single
// post-scan discography backfill run enriches before yielding. The
// remainder stay unenriched (discog_fetched = 0) and are picked up by
// the next run, so a large first-scan library spreads its enrichment
// across launches instead of running for the better part of an hour.
discogBackfillMaxPerRun = 2000
// discogBackfillWorkers is how many owned artists the backfill has
// in flight at once.
//
// The pass was strictly serial, so an artist's ListenBrainz calls,
// its MusicBrainz browse pages and every upstream's latency were
// paid end to end before the next artist began — while the limiters
// that actually keep us polite are per-host and were idle for most
// of it. Concurrency here does not raise the request rate against
// any origin; it stops one artist's slowest upstream deciding how
// fast the whole run goes.
discogBackfillWorkers = 6
// discogBackfillArtistTimeout bounds one artist's share of a run.
//
// The MusicBrainz client retries a 503 up to five times, honouring
// the server's Retry-After (which it caps at a minute), so a single
// throttled artist can otherwise hold a worker for longer than a
// hundred healthy ones take. A timed-out artist goes unmarked and
// is retried next run, which is what every other failure here does.
discogBackfillArtistTimeout = 90 * time.Second
// labsBaseURL is the base URL for the ListenBrainz labs API.
labsBaseURL = "https://labs.api.listenbrainz.org"
// labsSimilarAlgorithm is the algorithm parameter for the
// similar-artists endpoint.
labsSimilarAlgorithm = "session_based_days_7500_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30"
)
// 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"`
// Popularity signals.
Popularity int `json:"popularity"`
ListenerCount int `json:"listenerCount"`
// Recording-specific fields.
Duration int `json:"duration"` // milliseconds
CAAReleaseMBID string `json:"caaReleaseMbid"`
ReleaseName string `json:"releaseName"`
// Release-group-specific fields.
PrimaryType string `json:"primaryType"`
SecondaryTypes string `json:"secondaryTypes"` // comma-separated
ReleaseDate string `json:"releaseDate"`
// TotalTracks is the canonical release's track count, or 0 for
// "the catalog does not say". It is the denominator the album
// page cannot get from the files when the library holds no tags
// for the album, and it is deliberately only a denominator: the
// tracklist itself is not shipped, because the per-artist track
// budget truncates it and a truncated tracklist is a confident
// lie about which tracks exist.
TotalTracks int `json:"totalTracks"`
// Artist-specific fields (from MB lookup).
ArtistType string `json:"artistType"`
Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
SortName string `json:"sortName"`
// Personalization.
InLibrary bool `json:"inLibrary"`
IsSimilar bool `json:"isSimilar"`
// DiscogFetched marks a row as having had its full MusicBrainz
// metadata fetched. For artist rows it means the discography
// (release groups + recordings) was pulled by the indexer
// pipeline, and indexedArtistMBIDs() uses it to skip already-
// processed artists in tier 2/3. For release_group rows it means
// LookupReleaseGroup already did its one-time MB enrichment (e.g.
// secondary_types), so the album page's background lookup fires at
// most once per RG instead of on every visit.
DiscogFetched bool `json:"-"`
// Local library cross-reference (0 if not owned).
LocalArtistID int64 `json:"localArtistId,omitempty"`
LocalReleaseGroupID int64 `json:"localReleaseGroupId,omitempty"`
LocalRecordingID int64 `json:"localRecordingId,omitempty"`
}
// lbSitewideArtist is the response shape from the LB sitewide
// top-artists endpoint.
type lbSitewideArtist struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
ListenCount int `json:"listen_count"`
}
// SearchIndex maintains a local SQLite FTS5 index of popular
// albums and tracks. The index is populated in the background from
// the MetaBrainz dumps (see dumpimport.go) and patched incrementally
// via the ListenBrainz API:
//
// - Initial build: listens dump (popularity) + canonical dump (catalog)
// - Post-scan: API discographies for new library artists
// - Ongoing: organic growth from user browsing (AddFromCache)
type SearchIndex struct {
db *database.DB
lb *ListenBrainzClient
// mb is wired after construction (SetMusicBrainz) because the MB
// client is built alongside this one; it is only used by the
// owned-artist backfill, which tolerates its absence. Guarded by mu.
mb *MusicBrainzClient
artistImg *ArtistImageProvider
logger *slog.Logger
runtimeCtx context.Context // Wails runtime context for event emission
cancel context.CancelFunc
done chan struct{}
// netPolicy decides whether this connection is one to spend ~0.6 GB
// of catalog on. Its own lock: it is written once at startup and read
// from the build goroutine (netpolicy.go).
netPolicy networkPolicy
mu sync.RWMutex
ready bool
// championReady is set once the champion FTS (see
// RebuildChampionIndex) has been populated; championBuilding guards
// against overlapping rebuilds. Both are protected by mu.
championReady bool
championBuilding bool
// prefixCache memoises the raw index hits for generic short-prefix
// queries — the expensive ones — so repeats are served from memory.
prefixCacheMu sync.Mutex
prefixCache map[string]prefixCacheEntry
// discogSF dedupes concurrent lazy discography fetches for the same
// artist — the detail page fires its top-tracks, top-releases, and
// similar-artists requests in parallel, so without this the same
// artist would be fetched several times at once on first view.
discogSF singleflight.Group
// Build status tracking — read by GetIndexStatus for the UI.
buildStatus IndexStatus
// lastEmitted is the status most recently pushed to the frontend, so
// an unchanged one can be dropped rather than re-rendering the whole
// settings page for nothing. It has its own mutex: emitStatus is
// called from paths that already hold mu for reading.
emitMu sync.Mutex
lastEmitted *IndexStatus
// jobs is the background job registry; buildPaused records that the
// user paused the build, distinguishing a deliberate stop from a
// build that merely finished. Both are protected by mu.
jobs *jobs.Registry
buildPaused bool
}
// prefixCacheEntry is one memoised generic-query result.
type prefixCacheEntry struct {
hits []SearchIndexResult
created time.Time
}
// TierStatus represents the state of a single index tier.
type TierStatus struct {
Name string `json:"name"`
State string `json:"state"` // "pending", "running", "complete", "error", "skipped"
Total int `json:"total"`
Completed int `json:"completed"`
Error string `json:"error,omitempty"`
// Detail is a human-readable progress line for stages whose raw
// completed/total numbers say little on their own — the listens
// stream reports "42.3 / 205.1 GB · 18 MB/s · ~3h20m left" here.
Detail string `json:"detail,omitempty"`
}
// IndexStatus is the full index build status, exposed to the frontend.
type IndexStatus struct {
Building bool `json:"building"`
Ready bool `json:"ready"`
LastBuilt string `json:"lastBuilt,omitempty"` // RFC3339 timestamp of last complete build
Tiers []TierStatus `json:"tiers"`
Artists int `json:"artists"`
Recordings int `json:"recordings"`
ReleaseGroups int `json:"releaseGroups"`
TotalRows int `json:"totalRows"`
}
// NewSearchIndex creates a search index backed by the given
// database. Call StartBuild to kick off the background populate.
func NewSearchIndex(
db *database.DB,
lb *ListenBrainzClient,
artistImg *ArtistImageProvider,
logger *slog.Logger,
) *SearchIndex {
return &SearchIndex{
db: db,
lb: lb,
artistImg: artistImg,
logger: logger,
}
}
// SetContext injects the Wails runtime context for event emission.
func (si *SearchIndex) SetContext(ctx context.Context) {
si.runtimeCtx = ctx
// Initialize buildStatus with empty (non-nil) tiers so the
// frontend always receives a valid array, not JSON null.
si.mu.Lock()
if si.buildStatus.Tiers == nil {
si.buildStatus.Tiers = []TierStatus{}
}
si.mu.Unlock()
// Load current row counts + last-built timestamp from DB.
//
// There is deliberately no ticker here. This used to emit the status
// every 3 seconds for the life of the process, with a byte-identical
// payload once the index was ready — which re-rendered the whole of
// `config-page` on every tick, since it is a cached view that never
// unmounts. Every path that mutates the status already calls
// emitStatus, that call now suppresses an unchanged payload, and the
// frontend seeds itself with GetIndexStatus() on connect rather than
// waiting for the next tick.
si.refreshStatusCounts()
}
// EnsureArtistDiscography lazily fetches an artist's top release groups
// and recordings from ListenBrainz and persists them into the index —
// the first time the artist is actually needed (e.g. their detail page
// opens). It is a no-op once the artist is marked discog_fetched, so
// each artist costs at most one set of API calls, ever; every later
// view is served from the index with no network. This replaces the old
// post-scan sweep that eagerly fetched every library artist on startup.
//
// Concurrent calls for the same artist collapse into one via discogSF,
// since the detail page fires several requests for the same artist at
// once. Safe to call synchronously from a request handler: bounded work
// on its own rate-limited client.
func (si *SearchIndex) EnsureArtistDiscography(ctx context.Context, artistMBID string) {
if si.lb == nil || artistMBID == "" {
return
}
if si.artistDiscogFetched(artistMBID) {
return
}
_, _, _ = si.discogSF.Do(artistMBID, func() (any, error) {
// Re-check under the singleflight: a sibling call may have just
// finished fetching this artist while we were queued.
if si.artistDiscogFetched(artistMBID) {
return nil, nil
}
indexLB := NewListenBrainzClient(
NewRateLimiterN(indexerRate), si.lb.cache, si.logger.WithGroup("indexer"),
)
si.indexOneArtist(ctx, indexLB, lbSitewideArtist{
ArtistMBID: artistMBID,
ArtistName: si.artistDisplayName(artistMBID),
})
return nil, nil
})
}
// artistDiscogFetched reports whether the artist row already has its
// discography fetched, so EnsureArtistDiscography can skip the network.
func (si *SearchIndex) artistDiscogFetched(mbid string) bool {
rows, err := si.db.QueryContext(
"SELECT 1 FROM explore_index WHERE entity_type = 1 /* artist */ "+
"AND mbid = ? AND discog_fetched = 1 LIMIT 1",
dbMBID(mbid),
)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}
// unenrichedLibraryArtistMBIDs returns MBIDs for owned artists whose
// discography has not yet been fetched — either they have no index row
// or their row is still discog_fetched = 0, or the full MusicBrainz
// browse has not run. The LEFT JOINs key off
// persistent marks, so an artist enriched on a prior run (interactively
// or by an earlier backfill) never reappears, giving "new artists only"
// for free. Ordered by owned-track count so the artists the user has
// most of are enriched first. The limit bounds a single run (see
// discogBackfillMaxPerRun).
//
// The conditions are OR'd because they are different fetches: an
// artist covered by the downloaded catalog artifact arrives with
// discog_fetched = 1 and has still never been browsed, and the
// artifact's own per-artist coverage is graded — so "the artifact knows
// this artist" is not "we have their discography".
//
// similar_at is deliberately not one of them. The backfill no longer
// fetches similar artists, so testing the mark it does not set would
// make every owned artist a candidate on every run, forever.
func (si *SearchIndex) unenrichedLibraryArtistMBIDs(limit int) []string {
rows, err := si.db.QueryContext(`
SELECT a.mbid
FROM artists a
LEFT JOIN explore_index ei
ON ei.entity_type = 1 /* artist */ AND ei.mbid = unhex(replace(a.mbid, '-', ''))
LEFT JOIN artist_enrichment ae ON ae.artist_mbid = a.mbid
LEFT JOIN audio_files af ON af.artist_id = a.id
WHERE a.mbid IS NOT NULL AND a.mbid != ''
AND (ei.id IS NULL OR ei.discog_fetched = 0
OR ae.browsed_at IS NULL)
GROUP BY a.mbid
ORDER BY COUNT(DISTINCT af.id) DESC
LIMIT ?
`, limit)
if err != nil {
si.logger.Warn("discography backfill: query failed", "error", err)
return nil
}
defer func() { _ = rows.Close() }()
var mbids []string
for rows.Next() {
var mbid string
if err := rows.Scan(&mbid); err == nil {
mbids = append(mbids, mbid)
}
}
return mbids
}
// BackfillLibraryDiscographies makes an owned artist's page renderable
// offline, so their catalogue is there right after a scan instead of
// only on first view. Two fetches per artist, each skipped by its own
// persistent mark:
//
// - the ListenBrainz top release groups and recordings (marked by
// explore_index.discog_fetched), which is what popularity ordering
// and the top-tracks section need;
// - the full MusicBrainz browse (artist_enrichment.browsed_at), which
// is what makes the discography complete and typed — see
// browseFullDiscography.
//
// Similar artists are deliberately *not* fetched here. Nothing shows
// them until someone opens an artist page, and that page already
// resolves them on demand through Service.SimilarArtists →
// ensureSimilarArtistsAsync, which stamps the same similar_at mark.
// Fetching them for every owned artist bought a third of the run's
// requests for a section most of those artists will never have shown.
//
// It is bounded (discogBackfillMaxPerRun) and resumable: a cancelled or
// capped run continues on the next call, and each artist's marks are
// set as they complete rather than at the end. Each artist runs
// through discogSF so it never double-fetches one a concurrent
// interactive EnsureArtistDiscography is already handling, and under
// its own deadline so no single artist can stall the run.
func (si *SearchIndex) BackfillLibraryDiscographies(ctx context.Context) {
if si.lb == nil {
return
}
mbids := si.unenrichedLibraryArtistMBIDs(discogBackfillMaxPerRun)
if len(mbids) == 0 {
return
}
// Everything below is work nobody is waiting on, so it yields the
// shared MusicBrainz limiters to whatever the user is looking at.
ctx = WithBackgroundPriority(ctx)
job, ctx := startBackfillJob(
ctx, si.jobRegistry(), discogBackfillJobID,
"Filling in artist details",
"Discographies for artists in your library",
len(mbids),
)
defer func() { job.finish(ctx) }()
// One shared rate limiter paces the whole run, unlike the per-call
// client EnsureArtistDiscography builds for interactive fetches.
indexLB := NewListenBrainzClient(
NewRateLimiterN(indexerRate), si.lb.cache, si.logger.WithGroup("indexer"),
)
var (
wg sync.WaitGroup
done atomic.Int64
work = make(chan string)
)
wg.Add(discogBackfillWorkers)
for range discogBackfillWorkers {
go func() {
defer wg.Done()
for mbid := range work {
si.backfillOneArtist(ctx, indexLB, mbid)
job.progress(int(done.Add(1)), len(mbids))
}
}()
}
for _, mbid := range mbids {
if ctx.Err() != nil {
break
}
work <- mbid
}
close(work)
wg.Wait()
total := int(done.Load())
if ctx.Err() != nil {
si.logger.Info("discography backfill stopped",
"artists", total, "of", len(mbids),
)
return
}
job.logf(jobs.LevelInfo, "Filled in "+strconv.Itoa(total)+" artists")
si.logger.Info("discography backfill complete", "artists", total)
}
// backfillOneArtist runs one owned artist's fetches, under the
// singleflight that keeps it from racing an interactive fetch and under
// a deadline of its own (see discogBackfillArtistTimeout).
func (si *SearchIndex) backfillOneArtist(
ctx context.Context, lb *ListenBrainzClient, mbid string,
) {
if ctx.Err() != nil {
return
}
ctx, cancel := context.WithTimeout(ctx, discogBackfillArtistTimeout)
defer cancel()
_, _, _ = si.discogSF.Do(mbid, func() (any, error) {
// Re-checked under the singleflight: an interactive fetch may
// have done either of these since the query above.
if !si.artistDiscogFetched(mbid) {
si.indexOneArtist(ctx, lb, lbSitewideArtist{
ArtistMBID: mbid,
ArtistName: si.artistDisplayName(mbid),
})
}
if !si.enrichmentFor(mbid).Browsed {
si.browseFullDiscography(ctx, mbid)
}
return nil, nil
})
}
// artistDisplayName resolves a human-readable name for an artist MBID,
// preferring the index title, then the local library, then the MBID
// itself. Used to seed the discography fetch's artist entry.
func (si *SearchIndex) artistDisplayName(mbid string) string {
// The two tables spell an MBID differently: the catalog stores raw
// bytes, the library stores text. Each query brings its own form.
for _, q := range []struct {
sql string
arg any
}{
{"SELECT title FROM explore_index WHERE entity_type = 1 /* artist */ " +
"AND mbid = ? AND title != '' LIMIT 1", dbMBID(mbid)},
{"SELECT name FROM artists WHERE mbid = ? AND name != '' LIMIT 1", mbid},
} {
rows, err := si.db.QueryContext(q.sql, q.arg)
if err != nil {
continue
}
if rows.Next() {
var name string
if err := rows.Scan(&name); err == nil && name != "" {
_ = rows.Close()
return name
}
}
_ = rows.Close()
}
return mbid
}
// PersistSimilarArtists stores ListenBrainz similar-artist results into
// the similar_artist_map so future lookups are served locally instead of
// re-hitting the labs API on every artist-page view.
func (si *SearchIndex) PersistSimilarArtists(sourceMBID string, similar []LBSimilarArtist) {
if len(similar) == 0 {
return
}
wire := make([]lbSimilarArtistWire, len(similar))
for i, s := range similar {
wire[i] = lbSimilarArtistWire{
ArtistMBID: s.ArtistMBID,
Name: s.Name,
Score: int(s.Score),
}
}
si.storeSimilarArtists(sourceMBID, wire)
}
// StartBuild launches the background index build goroutine.
// Returns immediately.
func (si *SearchIndex) StartBuild(ctx context.Context) {
// A build the user paused stays paused until they resume it —
// including across restarts, where the marker is read back from
// explore_index_meta. ResumeBuild clears it before calling here.
if si.buildPausedByUser() {
si.logger.Info("search index: build is paused, not starting")
return
}
si.mu.Lock()
// Don't start if already running.
if si.cancel != nil {
si.mu.Unlock()
return
}
si.done = make(chan struct{})
si.mu.Unlock()
buildCtx, cancel := context.WithCancel(ctx)
si.mu.Lock()
si.cancel = cancel
si.mu.Unlock()
go func() {
defer func() {
si.mu.Lock()
si.cancel = nil
si.mu.Unlock()
// `Building` is derived from si.cancel, so clearing it is a
// status change and has to say so. This is what resolves the
// job in the registry — syncIndexJob only finishes a job on a
// sync that reports Building false, and without this line the
// header badge reads "Building search index" over an index the
// settings page calls ready.
si.emitStatus()
close(si.done)
// The index rows (and their popularities) may have changed, so
// refresh the champion tier once the build settles.
si.scheduleChampionRebuild()
}()
si.runDumpBuild(buildCtx)
}()
}
// StopBuild cancels an in-flight build and waits for it to finish.
// Safe to call even if no build is running.
func (si *SearchIndex) StopBuild() {
si.mu.RLock()
cancel := si.cancel
done := si.done
si.mu.RUnlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
}
// WaitForIdle blocks until no build or indexing goroutine is running.
// Unlike StopBuild, this does NOT cancel a running build.
func (si *SearchIndex) WaitForIdle() {
si.mu.RLock()
done := si.done
si.mu.RUnlock()
if done != nil {
<-done
}
}
// IsReady returns true once the index has been built at least once.
func (si *SearchIndex) IsReady() bool {
si.mu.RLock()
defer si.mu.RUnlock()
return si.ready
}
// GetIndexStatus returns the current index build status for the UI.
// Entirely in-memory — no DB queries — to avoid blocking the Wails
// UI thread when the index build holds a write lock.
func (si *SearchIndex) GetIndexStatus() IndexStatus {
si.mu.RLock()
status := si.buildStatus
status.Ready = si.ready
status.Building = si.cancel != nil
si.mu.RUnlock()
return status
}
// refreshStatusCounts updates the row counts and last-built timestamp
// in buildStatus from the DB. Called between tiers when the DB is idle.
func (si *SearchIndex) refreshStatusCounts() {
var artists, recordings, rgs int
rows, err := si.db.QueryContext(`
SELECT entity_type, COUNT(*) FROM explore_index GROUP BY entity_type
`)
if err == nil {
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
et dbEntityType
count int
)
if err := rows.Scan(&et, &count); err == nil {
switch string(et) {
case EntityArtist:
artists = count
case EntityRecording:
recordings = count
case EntityReleaseGroup:
rgs = count
}
}
}
}
var lastBuilt string
metaRow, err := si.db.QueryContext(
"SELECT value FROM explore_index_meta WHERE key = 'dump_import_done'",
)
if err == nil {
defer func() { _ = metaRow.Close() }()
if metaRow.Next() {
_ = metaRow.Scan(&lastBuilt)
}
}
si.mu.Lock()
si.buildStatus.Artists = artists
si.buildStatus.Recordings = recordings
si.buildStatus.ReleaseGroups = rgs
si.buildStatus.TotalRows = artists + recordings + rgs
si.buildStatus.LastBuilt = lastBuilt
si.mu.Unlock()
si.emitStatus()
}
// setTierStatus updates the build status for a named tier.
func (si *SearchIndex) setTierStatus(name, state string, total, completed int) {
si.setTierDetail(name, state, total, completed, "")
}
// setTierDetail is setTierStatus plus a human-readable progress line.
func (si *SearchIndex) setTierDetail(name, state string, total, completed int, detail string) {
si.mu.Lock()
for i := range si.buildStatus.Tiers {
if si.buildStatus.Tiers[i].Name == name {
transitioned := si.buildStatus.Tiers[i].State != state
si.buildStatus.Tiers[i].State = state
si.buildStatus.Tiers[i].Total = total
si.buildStatus.Tiers[i].Completed = completed
si.buildStatus.Tiers[i].Detail = detail
si.mu.Unlock()
if transitioned {
si.logIndexJob(jobs.LevelInfo, "Stage "+state+": "+name)
}
si.emitStatus()
return
}
}
si.buildStatus.Tiers = append(si.buildStatus.Tiers, TierStatus{
Name: name,
State: state,
Total: total,
Completed: completed,
Detail: detail,
})
si.mu.Unlock()
si.emitStatus()
}
// sameStatusAs reports whether two statuses would render identically.
// IndexStatus holds a slice, so it is not comparable with ==.
func (s IndexStatus) sameStatusAs(o IndexStatus) bool {
if s.Building != o.Building ||
s.Ready != o.Ready ||
s.LastBuilt != o.LastBuilt ||
s.Artists != o.Artists ||
s.Recordings != o.Recordings ||
s.ReleaseGroups != o.ReleaseGroups ||
s.TotalRows != o.TotalRows ||
len(s.Tiers) != len(o.Tiers) {
return false
}
for i := range s.Tiers {
if s.Tiers[i] != o.Tiers[i] {
return false
}
}
return true
}
// emitStatus pushes the current index status to the frontend via Wails
// event — but only when it differs from the last one pushed.
//
// The status is emitted from every path that touches it, several of
// which report progress in a tight loop, and the frontend's handler
// assigns to a @state field: an identical payload is therefore a full
// re-render of a 2 000-line template saying nothing. Deduplicating
// here rather than at the call sites means no future emitter has to
// remember (`perf.M6` / `H-14`).
func (si *SearchIndex) emitStatus() {
if si.runtimeCtx == nil {
return
}
si.mu.RLock()
status := si.buildStatus
status.Ready = si.ready
status.Building = si.cancel != nil
si.mu.RUnlock()
si.emitMu.Lock()
unchanged := si.lastEmitted != nil &&
si.lastEmitted.sameStatusAs(status)
if !unchanged {
snapshot := status
snapshot.Tiers = append(
[]TierStatus(nil), status.Tiers...,
)
si.lastEmitted = &snapshot
}
si.emitMu.Unlock()
if unchanged {
return
}
events.Emit(si.runtimeCtx, events.IndexStatusChanged, status)
// Mirror into the shared job registry. Every status mutation goes
// through emitStatus, so hooking here covers all update paths.
si.syncIndexJob(status)
}
// GetPopularity returns the cached popularity (listen count) for
// the given MBID from the local index. Returns 0 if not found.
func (si *SearchIndex) GetPopularity(mbid string) int {
if mbid == "" {
return 0
}
rows, err := si.db.QueryContext(
"SELECT popularity FROM explore_index WHERE mbid = ? LIMIT 1",
dbMBID(mbid),
)
if err != nil {
return 0
}
defer func() { _ = rows.Close() }()
if rows.Next() {
var pop int
if err := rows.Scan(&pop); err == nil {
return pop
}
}
return 0
}
// PopularityBatchResult contains popularity, listener count, library
// status, and similarity scores for a batch of MBIDs.
type PopularityBatchResult struct {
Popularity map[string]int
ListenerCount map[string]int
InLibrary map[string]bool
SimilarityScores map[string]int // max similarity score (0 = not similar)
}
// GetPopularityBatch returns popularity (listen count) and library
// status for multiple MBIDs in a single query.
func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult {
if len(mbids) == 0 {
return nil
}
placeholders := make([]string, len(mbids))
args := make([]any, len(mbids))
for i, m := range mbids {
placeholders[i] = "?"
args[i] = dbMBID(m)
}
query := "SELECT mbid, popularity, listener_count, in_library FROM explore_index WHERE mbid IN (" +
strings.Join(
placeholders,
",",
) + ")"
rows, err := si.db.QueryContext(query, args...)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
result := &PopularityBatchResult{
Popularity: make(map[string]int, len(mbids)),
ListenerCount: make(map[string]int),
InLibrary: make(map[string]bool),
SimilarityScores: make(map[string]int),
}
for rows.Next() {
var (
id dbMBID
pop int
listeners int
inLib int
)
if err := rows.Scan(&id, &pop, &listeners, &inLib); err == nil {
mbid := string(id)
existing, ok := result.Popularity[mbid]
if !ok || pop > existing {
result.Popularity[mbid] = pop
}
if listeners > 0 {
result.ListenerCount[mbid] = listeners
}
if inLib == 1 {
result.InLibrary[mbid] = true
}
}
}
// Fetch similarity scores from the map table.
result.SimilarityScores = si.GetSimilarityScores(mbids)
return result
}
// IsInLibrary returns whether the given MBID is marked as in the
// user's local library in the search index.
func (si *SearchIndex) IsInLibrary(mbid string) bool {
if mbid == "" {
return false
}
rows, err := si.db.QueryContext(
"SELECT in_library FROM explore_index WHERE mbid = ? AND in_library = 1 LIMIT 1",
dbMBID(mbid),
)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}
// LookupArtistByMBID reads a single artist row from the index, including
// all metadata fields (type, country, disambiguation, sort_name).
// Returns nil if the artist isn't indexed.
func (si *SearchIndex) LookupArtistByMBID(mbid string) *SearchIndexResult {
rows, err := si.db.QueryContext(
`SELECT title, artist_name, artist_mbid, popularity, listener_count,
artist_type, country, disambiguation, sort_name, aliases,
in_library, is_similar, COALESCE(local_artist_id, 0)
FROM explore_index
WHERE mbid = ? AND entity_type = 1 /* artist */ LIMIT 1`,
dbMBID(mbid),
)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return nil
}
r := SearchIndexResult{
EntityType: EntityArtist,
MBID: mbid,
}
var artist dbMBID
if err := rows.Scan(
&r.Title, &r.ArtistName, &artist, &r.Popularity, &r.ListenerCount,
&r.ArtistType, &r.Country, &r.Disambiguation, &r.SortName, &r.Aliases,
&r.InLibrary, &r.IsSimilar, &r.LocalArtistID,
); err != nil {
return nil
}
r.ArtistMBID = string(artist)
return &r
}
// ReleaseGroupMBIDsForCAAReleaseMBIDs takes a list of release MBIDs
// (from recording.caa_release_mbid) and returns a map from release
// MBID → release group MBID, by joining against the release_group
// 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 {
if len(caaReleaseMBIDs) == 0 {
return nil
}
// Filter out empty strings — an empty input MBID would match
// every release_group row that also has an empty caa_release_mbid,
// producing false positives that resolve to release groups with
// no actual cover art (e.g. bootlegs, demos).
filtered := make([]string, 0, len(caaReleaseMBIDs))
seen := make(map[string]struct{}, len(caaReleaseMBIDs))
for _, m := range caaReleaseMBIDs {
if m == "" {
continue
}
if _, dup := seen[m]; dup {
continue
}
seen[m] = struct{}{}
filtered = append(filtered, m)
}
if len(filtered) == 0 {
return nil
}
placeholders := make([]string, len(filtered))
args := make([]any, len(filtered))
for i, m := range filtered {
placeholders[i] = "?"
args[i] = dbMBID(m)
}
query := `SELECT caa_release_mbid, mbid
FROM explore_index
WHERE entity_type = 2 /* release_group */
AND caa_release_mbid != x''
AND caa_release_mbid IN (` + strings.Join(placeholders, ",") + `)`
rows, err := si.db.QueryContext(query, args...)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
out := make(map[string]string, len(filtered))
for rows.Next() {
var caaMBID, rgMBID dbMBID
if err := rows.Scan(&caaMBID, &rgMBID); err == nil {
out[string(caaMBID)] = string(rgMBID)
}
}
return out
}
// LookupReleaseGroupByMBID reads a single release group row from the index.
func (si *SearchIndex) LookupReleaseGroupByMBID(mbid string) *SearchIndexResult {
rows, err := si.db.QueryContext(
`SELECT title, artist_name, artist_mbid, popularity, listener_count,
primary_type, secondary_types, release_date, total_tracks,
in_library, COALESCE(local_release_group_id, 0), discog_fetched
FROM explore_index
WHERE mbid = ? AND entity_type = 2 /* release_group */ LIMIT 1`,
dbMBID(mbid),
)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return nil
}
r := SearchIndexResult{
EntityType: EntityReleaseGroup,
MBID: mbid,
}
var artist dbMBID
if err := rows.Scan(
&r.Title, &r.ArtistName, &artist, &r.Popularity, &r.ListenerCount,
&r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, &r.TotalTracks,
&r.InLibrary, &r.LocalReleaseGroupID, &r.DiscogFetched,
); err != nil {
return nil
}
return &r
}
// PersistReleaseGroupLookup writes the MB-enriched metadata for a
// single release group back into the index and marks it DiscogFetched
// so LookupReleaseGroup's background enrichment runs at most once per
// RG. Popularity is left at 0 here — upsertBatch keeps the higher of
// old/new, so the dump-provided popularity is never clobbered.
func (si *SearchIndex) PersistReleaseGroupLookup(rg *MBReleaseGroup) {
if rg == nil || rg.MBID == "" {
return
}
si.upsertBatch([]SearchIndexResult{{
EntityType: "release_group",
MBID: rg.MBID,
Title: rg.Title,
ArtistName: rg.ArtistCredit,
PrimaryType: rg.PrimaryType,
SecondaryTypes: strings.Join(rg.SecondaryTypes, ","),
ReleaseDate: rg.FirstReleaseDate,
DiscogFetched: true,
}})
}
// TopRecordingsByArtist returns the most popular recordings for an
// artist MBID from the index, ordered by popularity descending.
// Falls back to returning entries without popularity data if there
// aren't enough popular ones.
func (si *SearchIndex) TopRecordingsByArtist(artistMBID string, limit int) []SearchIndexResult {
rows, err := si.db.QueryContext(
`SELECT mbid, title, artist_name, popularity, listener_count,
duration, caa_release_mbid, release_name,
in_library, COALESCE(local_recording_id, 0)
FROM explore_index
WHERE artist_mbid = ? AND entity_type = 3 /* recording */
ORDER BY popularity DESC
LIMIT ?`,
dbMBID(artistMBID), limit,
)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
var results []SearchIndexResult
for rows.Next() {
var (
r SearchIndexResult
id, caa dbMBID
)
if err := rows.Scan(
&id, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount,
&r.Duration, &caa, &r.ReleaseName,
&r.InLibrary, &r.LocalRecordingID,
); err == nil {
r.MBID = string(id)
r.CAAReleaseMBID = string(caa)
r.EntityType = EntityRecording
r.ArtistMBID = artistMBID
results = append(results, r)
}
}
return results
}
// TopReleaseGroupsByArtist returns the most popular release groups
// for an artist MBID from the index, ordered by popularity descending.
// Falls back to returning entries without popularity data if there
// aren't enough popular ones.
func (si *SearchIndex) TopReleaseGroupsByArtist(artistMBID string, limit int) []SearchIndexResult {
rows, err := si.db.QueryContext(
`SELECT mbid, title, artist_name, popularity, listener_count,
primary_type, secondary_types, release_date,
in_library, COALESCE(local_release_group_id, 0)
FROM explore_index
WHERE artist_mbid = ? AND entity_type = 2 /* release_group */
ORDER BY popularity DESC
LIMIT ?`,
dbMBID(artistMBID), limit,
)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
var results []SearchIndexResult
for rows.Next() {
var (
r SearchIndexResult
id dbMBID
)
if err := rows.Scan(
&id, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount,
&r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate,
&r.InLibrary, &r.LocalReleaseGroupID,
); err == nil {
r.MBID = string(id)
r.EntityType = EntityReleaseGroup
r.ArtistMBID = artistMBID
results = append(results, r)
}
}
return results
}
// AddFromCache inserts entries from a cached discography browse into
// the search index. Called when a user views an artist page and the
// discography is fetched — organic growth beyond the shipped catalog.
func (si *SearchIndex) AddFromCache(artistName, artistMBID string, rgs []MBReleaseGroup) {
if len(rgs) == 0 {
return
}
// resolveArtistName falls back to the MBID when it cannot find a
// name, which is fine for a one-off render but must never be
// persisted: an MBID stored as a title is unsearchable and shows up
// as a UUID in the UI. Writing nothing lets the upsert's
// "non-empty wins" rule keep whatever real name arrives later.
if artistName == artistMBID {
artistName = ""
}
entries := make([]SearchIndexResult, 0, len(rgs)+1)
// Add the artist itself, unless there is no name to add.
if artistName != "" {
entries = append(entries, SearchIndexResult{
EntityType: "artist",
MBID: artistMBID,
Title: artistName,
ArtistName: artistName,
ArtistMBID: artistMBID,
Popularity: 0, // Unknown from this path.
})
}
for _, rg := range rgs {
entries = append(entries, SearchIndexResult{
EntityType: "release_group",
MBID: rg.MBID,
Title: rg.Title,
ArtistName: artistName,
ArtistMBID: artistMBID,
Popularity: 0,
PrimaryType: rg.PrimaryType,
SecondaryTypes: strings.Join(rg.SecondaryTypes, ","),
ReleaseDate: rg.FirstReleaseDate,
})
}
si.upsertBatch(entries)
si.logger.Debug("search index: organic add",
"artist", artistName,
"releaseGroups", len(rgs),
)
}
// 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
// against high-popularity entities are almost always the right
// answer and should bypass the noise of MB text search.
//
// Returns up to `perCategory` matches per entity type, ordered by
// popularity descending. Case-insensitive; trims whitespace.
func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndexResult {
if !si.IsReady() {
return nil
}
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return nil
}
if perCategory <= 0 {
perCategory = 3
}
// UNION of two equality lookups rather than `LOWER(title) = ? OR
// LOWER(artist_name) = ?`. The OR form forces SQLite to scan all
// ~240k rows; splitting into two equalities lets each branch seek
// its partial expression index (idx_explore_title_lower /
// idx_explore_artist_lower). Ordering is done in Go below since
// an ORDER BY here would also defeat the index seek.
//
// The popularity clause **must match those indexes' predicate** or
// the seek becomes a scan of two million rows. It is the champion
// set: what the user owns, plus what is popular enough to be worth
// an exact-match boost. Anything below the floor is still found by
// the FTS tiers; it just does not jump the queue.
rows, err := si.db.QueryContext(`
SELECT `+indexRowColumns+`
FROM explore_index
WHERE LOWER(title) = ? AND (popularity >= ? OR in_library = 1)
UNION
SELECT `+indexRowColumns+`
FROM explore_index
WHERE LOWER(artist_name) = ? AND (popularity >= ? OR in_library = 1)
`, q, championPopThreshold, q, championPopThreshold)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
var matches []SearchIndexResult
for rows.Next() {
var r SearchIndexResult
if err := scanIndexRow(rows, &r); err != nil {
continue
}
// For artists, only match on title (name). For recordings
// and release groups, match on either title or artist name
// — that way "miley cyrus" surfaces both the artist and
// her recordings.
titleMatch := strings.ToLower(r.Title) == q
artistMatch := strings.ToLower(r.ArtistName) == q
if r.EntityType == "artist" && !titleMatch {
continue
}
if r.EntityType != "artist" && !titleMatch && !artistMatch {
continue
}
matches = append(matches, r)
}
// Sort by popularity desc so the per-category cap keeps the most
// popular entries (the old SQL ORDER BY guaranteed this; UNION
// does not preserve order).
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].Popularity > matches[j].Popularity
})
// Group by entity type, capping at perCategory each.
buckets := map[string][]SearchIndexResult{
"artist": nil,
"release_group": nil,
"recording": nil,
}
for _, r := range matches {
bucket := buckets[r.EntityType]
if len(bucket) >= perCategory {
continue
}
buckets[r.EntityType] = append(bucket, r)
}
var out []SearchIndexResult
out = append(out, buckets["artist"]...)
out = append(out, buckets["release_group"]...)
out = append(out, buckets["recording"]...)
return out
}
// 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(ctx context.Context, query string, limit int) []SearchIndexResult {
// IsReady is latched once at startup, so it is right in the app and
// wrong for anything that fills the table afterwards — including the
// e2e suite staging a catalog, which is how search gets tested in
// CI, where the artifact URL points at a dead address on purpose.
// shelves.go learned this already; the search path did not, and the
// symptom was three specs that passed only when an earlier one
// happened to flip the flag first.
//
// The latch stays as the fast path — once it is true nothing can
// make it false — and the probe is one indexed `SELECT 1 … LIMIT 1`
// on the only path that could otherwise answer "no catalog" wrongly.
if !si.IsReady() && !si.hasCatalogRows(ctx) {
return nil
}
if limit <= 0 {
limit = 20
}
ftsQuery := buildFTSQuery(query)
if ftsQuery == "" {
return nil
}
generic := isGenericQuery(query)
// Generic short-prefix queries are the slow ones; serve repeats from
// the in-memory cache and route the rest through the champion tier.
if generic {
if hits, ok := si.prefixCacheGet(ftsQuery, limit); ok {
return hits
}
}
hits := si.runSearch(ctx, ftsQuery, limit, generic)
// Typo-tolerant fallback: a thin result usually means the query is
// misspelled and matched no token prefix. Rescue with bigram
// similarity over the champion set before giving up. Skipped once a
// search is superseded (ctx cancelled) — the result is discarded
// anyway.
if len(hits) < fuzzyRescueThinHits && ctx.Err() == nil {
hits = si.appendFuzzyRescue(ctx, query, limit, hits)
}
// Cache only fully-formed generic results — never a partial list from
// a superseded (cancelled) query.
if generic && ctx.Err() == nil {
si.prefixCachePut(ftsQuery, limit, hits)
}
return hits
}
// appendFuzzyRescue runs the bigram rescue pass and merges its hits into
// the existing (thin) result, de-duplicated by MBID and preserving the
// original hits first. Returns the original slice unchanged when rescue
// finds nothing new.
func (si *SearchIndex) appendFuzzyRescue(
ctx context.Context,
query string,
limit int,
hits []SearchIndexResult,
) []SearchIndexResult {
rescued := si.fuzzyRescue(ctx, query, limit)
if len(rescued) == 0 {
return hits
}
seen := make(map[string]struct{}, len(hits)+len(rescued))
for _, h := range hits {
seen[h.MBID] = struct{}{}
}
for _, r := range rescued {
if _, dup := seen[r.MBID]; dup {
continue
}
seen[r.MBID] = struct{}{}
hits = append(hits, r)
if len(hits) >= limit {
break
}
}
return hits
}
// fuzzyRescue is the typo-tolerant fallback for Search. It scans the
// champion set (high-popularity + owned rows) and scores each candidate
// by character-bigram overlap (Dice) against its title and artist name,
// so a misspelled query still surfaces its intended entity even though it
// shares no token prefix with any indexed name. Returns up to `limit`
// hits above fuzzyRescueMinScore, ordered by fuzzy score with popularity
// as a tie-break. Bounded work that fires only on the rare thin query,
// so it never touches the fast common path.
func (si *SearchIndex) fuzzyRescue(
ctx context.Context,
query string,
limit int,
) []SearchIndexResult {
qSet := fuzzyBigrams(query)
if len([]rune(fuzzyNormalize(query))) < fuzzyRescueMinLen || len(qSet) == 0 {
return nil
}
// Champion membership predicate, mirrored from rebuildChampionIndex:
// a row below the popularity floor that isn't owned can't be a
// meaningful popular match, so scoring it would only add noise.
rows, err := si.db.QueryContextWith(ctx, `
SELECT id, title, artist_name, popularity
FROM explore_index
WHERE popularity >= ? OR in_library = 1
`, championPopThreshold)
if err != nil {
if !errors.Is(err, context.Canceled) {
si.logger.Warn("fuzzy rescue scan error", "error", err)
}
return nil
}
defer func() { _ = rows.Close() }()
type scored struct {
id int64
score float64
pop int
}
var candidates []scored
scanned := 0
for rows.Next() {
// Periodically honour cancellation: a superseded search shouldn't
// keep scoring tens of thousands of rows.
scanned++
if scanned%4096 == 0 && ctx.Err() != nil {
return nil
}
var (
id int64
title string
artist string
pop int
)
if err := rows.Scan(&id, &title, &artist, &pop); err != nil {
continue
}
score := diceCoefficient(qSet, fuzzyBigrams(title))
if s := diceCoefficient(qSet, fuzzyBigrams(artist)); s > score {
score = s
}
if score >= fuzzyRescueMinScore {
candidates = append(candidates, scored{id: id, score: score, pop: pop})
}
}
if len(candidates) == 0 {
return nil
}
// Best bigram match first; popularity breaks near-ties so the more
// prominent entity wins when two names are equally close.
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].score != candidates[j].score {
return candidates[i].score > candidates[j].score
}
return candidates[i].pop > candidates[j].pop
})
if len(candidates) > limit {
candidates = candidates[:limit]
}
ids := make([]int64, len(candidates))
for i, c := range candidates {
ids[i] = c.id
}
return si.rowsByIDs(ctx, ids)
}
// rowsByIDs loads full index rows for the given explore_index ids,
// returned in the same order as `ids` (rows are keyed by id in a map,
// then re-emitted in input order). Used by fuzzyRescue to hydrate the
// lightweight candidate ids it scored into full search results.
func (si *SearchIndex) rowsByIDs(ctx context.Context, ids []int64) []SearchIndexResult {
if len(ids) == 0 {
return nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := `
SELECT id, ` + indexRowColumns + `
FROM explore_index
WHERE id IN (` + strings.Join(placeholders, ",") + `)`
rows, err := si.db.QueryContextWith(ctx, query, args...)
if err != nil {
if !errors.Is(err, context.Canceled) {
si.logger.Warn("fuzzy rescue hydrate error", "error", err)
}
return nil
}
defer func() { _ = rows.Close() }()
byID := make(map[int64]SearchIndexResult, len(ids))
for rows.Next() {
var (
id int64
r SearchIndexResult
)
if err := scanIndexRow(rows, &r, &id); err != nil {
continue
}
byID[id] = r
}
out := make([]SearchIndexResult, 0, len(ids))
for _, id := range ids {
if r, ok := byID[id]; ok {
out = append(out, r)
}
}
return out
}
// runSearch dispatches an FTS query to the champion tier when the query
// is generic and the champion index is ready, falling back to the full
// index when the champion result is thin (few high-popularity matches)
// or the champion index isn't built yet.
func (si *SearchIndex) runSearch(
ctx context.Context,
ftsQuery string,
limit int,
generic bool,
) []SearchIndexResult {
if generic {
if si.championIsReady() {
cStart := time.Now()
hits := si.queryFTS(ctx, "explore_champion_fts", ftsQuery, limit)
if len(hits) >= limit {
si.logger.Info("search path: champion",
"fts", ftsQuery,
"rows", len(hits),
"elapsed", time.Since(cStart).Round(time.Millisecond),
)
return hits
}
// Thin champion result: the full index is authoritative, and a
// query the champion couldn't fill matches few rows there too.
si.logger.Info("search path: champion thin -> full",
"fts", ftsQuery,
"champion_rows", len(hits),
"champion_elapsed", time.Since(cStart).Round(time.Millisecond),
)
} else {
// First generic query before the champion exists — build it in
// the background so the next one is fast.
si.logger.Info("search path: champion not ready -> full", "fts", ftsQuery)
si.scheduleChampionRebuild()
}
}
fStart := time.Now()
hits := si.queryFTS(ctx, "explore_index_fts", ftsQuery, limit)
if generic {
si.logger.Info("search path: full",
"fts", ftsQuery,
"rows", len(hits),
"elapsed", time.Since(fStart).Round(time.Millisecond),
)
}
return hits
}
// queryFTS runs the popularity-blended ranking query against the named
// FTS table (the full index or the champion tier — identical schema, so
// the only difference is how many rows MATCH).
func (si *SearchIndex) queryFTS(
ctx context.Context,
ftsTable, ftsQuery string,
limit int,
) []SearchIndexResult {
// ftsTable is a trusted in-package constant, never user input.
sqlText := fmt.Sprintf(`
SELECT `+indexRowColumnsFor("i")+`
FROM explore_index i
JOIN %[1]s f ON f.rowid = i.id
WHERE %[1]s MATCH ?
ORDER BY bm25(%[1]s, 3.0, 1.0, 0.5)
- (ln(i.popularity + 1) * 1.5)
- (i.in_library * 3.0)
- (i.is_similar * 1.5)
LIMIT ?
`, ftsTable)
rows, err := si.db.QueryContextWith(ctx, sqlText, ftsQuery, limit)
if err != nil {
// A superseded search has its context cancelled on purpose; that
// is expected shutdown, not a query fault, so don't warn on it.
if !errors.Is(err, context.Canceled) {
si.logger.Warn("search index query error",
"table", ftsTable,
"ftsQuery", ftsQuery,
"error", err,
)
}
return nil
}
defer func() { _ = rows.Close() }()
var results []SearchIndexResult
for rows.Next() {
var r SearchIndexResult
if err := scanIndexRow(rows, &r); err != nil {
si.logger.Warn("search index scan error", "error", err)
continue
}
results = append(results, r)
}
return results
}
// isGenericQuery reports whether a query has no selective (long) token
// and therefore matches a broad slice of the index — the case the
// champion tier and prefix cache exist to accelerate.
func isGenericQuery(query string) bool {
words := splitWords(query)
if len(words) == 0 {
return false
}
for _, w := range words {
if len([]rune(w)) > genericMaxTokenLen {
return false
}
}
return true
}
// ---------------------------------------------------------------------------
// Champion tier
// ---------------------------------------------------------------------------
// championIsReady reports whether the champion FTS is populated.
func (si *SearchIndex) championIsReady() bool {
si.mu.RLock()
defer si.mu.RUnlock()
return si.championReady
}
// scheduleChampionRebuild rebuilds the champion FTS in the background,
// unless one is already running or the main index isn't ready yet. The
// rebuild runs in a single write transaction, so concurrent searches
// keep reading the previous champion snapshot (WAL) until it commits.
func (si *SearchIndex) scheduleChampionRebuild() {
si.mu.Lock()
if si.championBuilding || !si.ready {
si.mu.Unlock()
return
}
si.championBuilding = true
si.mu.Unlock()
go func() {
// Use a stable context, not any per-search one, so a superseded
// search can't cancel the rebuild midway.
ctx := si.runtimeCtx
if ctx == nil {
ctx = context.Background()
}
start := time.Now()
err := si.rebuildChampionIndex(ctx)
si.mu.Lock()
si.championBuilding = false
// Keep a previously-good champion usable if a refresh failed; mark
// ready on success.
si.championReady = si.championReady || err == nil
si.mu.Unlock()
// The champion set changed, so any memoised generic results are
// stale.
si.clearPrefixCache()
if err != nil {
si.logger.Warn("champion index rebuild failed", "error", err)
return
}
si.setMeta(championBuiltKey, time.Now().UTC().Format(time.RFC3339))
si.logger.Info("champion index rebuilt",
"elapsed", time.Since(start).Round(time.Millisecond),
)
}()
}
// rebuildChampionIndex repopulates the champion FTS from the high-
// popularity and owned rows of explore_index. A row below the
// popularity floor can never place in a generic query's top results
// (ranking there is popularity-dominated), so dropping it is lossless
// for those queries; owned rows are always kept for their in-library
// boost.
func (si *SearchIndex) rebuildChampionIndex(ctx context.Context) error {
tx, err := si.db.BeginTx()
if err != nil {
return fmt.Errorf("begin champion rebuild: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO explore_champion_fts(explore_champion_fts) VALUES('delete-all')`,
); err != nil {
return fmt.Errorf("clear champion fts: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO explore_champion_fts(rowid, title, artist_name, aliases)
SELECT id, title, artist_name, aliases
FROM explore_index
WHERE popularity >= ? OR in_library = 1
`, championPopThreshold); err != nil {
return fmt.Errorf("populate champion fts: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit champion rebuild: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Prefix result cache
// ---------------------------------------------------------------------------
func prefixCacheKey(ftsQuery string, limit int) string {
return strconv.Itoa(limit) + "\x00" + ftsQuery
}
func (si *SearchIndex) prefixCacheGet(ftsQuery string, limit int) ([]SearchIndexResult, bool) {
si.prefixCacheMu.Lock()
defer si.prefixCacheMu.Unlock()
key := prefixCacheKey(ftsQuery, limit)
entry, ok := si.prefixCache[key]
if !ok {
return nil, false
}
if time.Since(entry.created) > prefixCacheTTL {
delete(si.prefixCache, key)
return nil, false
}
return entry.hits, true
}
func (si *SearchIndex) prefixCachePut(ftsQuery string, limit int, hits []SearchIndexResult) {
si.prefixCacheMu.Lock()
defer si.prefixCacheMu.Unlock()
if si.prefixCache == nil {
si.prefixCache = make(map[string]prefixCacheEntry)
}
// Generic keys are few and cheap to recompute; when the cap is hit,
// drop everything rather than track per-entry LRU state.
if len(si.prefixCache) >= prefixCacheMaxEntries {
si.prefixCache = make(map[string]prefixCacheEntry)
}
si.prefixCache[prefixCacheKey(ftsQuery, limit)] = prefixCacheEntry{
hits: hits,
created: time.Now(),
}
}
func (si *SearchIndex) clearPrefixCache() {
si.prefixCacheMu.Lock()
si.prefixCache = nil
si.prefixCacheMu.Unlock()
}
// ---------------------------------------------------------------------------
// FTS query building
// ---------------------------------------------------------------------------
func buildFTSQuery(query string) string {
words := splitWords(query)
if len(words) == 0 {
return ""
}
var b strings.Builder
for i, w := range words {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(w)
b.WriteByte('*')
}
return b.String()
}
func splitWords(s string) []string {
var words []string
current := ""
for _, r := range s {
if isWordChar(r) {
current += string(r)
} else if current != "" {
words = append(words, current)
current = ""
}
}
if current != "" {
words = append(words, current)
}
return words
}
func isWordChar(r rune) bool {
return (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r >= 0x80
}
type lbSimilarArtistWire struct {
ArtistMBID string `json:"artist_mbid"`
Name string `json:"name"`
Score int `json:"score"`
ReferenceMBID string `json:"reference_mbid"` // which seed artist this result belongs to
}
// ---------------------------------------------------------------------------
// Shared: index artist discographies
// ---------------------------------------------------------------------------
func (si *SearchIndex) indexOneArtist(
ctx context.Context,
lb *ListenBrainzClient,
artist lbSitewideArtist,
) {
if ctx.Err() != nil {
return
}
rgLimit, recLimit := indexMaxRGs, indexMaxRecs
// Run LB discography fetches and MB artist image resolution
// concurrently — they use different rate limiters so they
// don't block each other.
var (
rgs []SearchIndexResult
recs []SearchIndexResult
rgErr error
recErr error
wg sync.WaitGroup
)
// LB pipeline: top release groups + top recordings.
wg.Add(1)
go func() {
defer wg.Done()
rgs, rgErr = si.fetchTopReleaseGroups(ctx, lb, artist, rgLimit)
recs, recErr = si.fetchTopRecordings(ctx, lb, artist, recLimit)
}()
// MB pipeline: cache the artist lookup, which is what the details
// below are read from (uses MB rate limiter).
//
// Deliberately *not* GetArtistImage. This function wants the MB
// artist response; that entry point additionally queried fanart.tv,
// TheAudioDB, Wikidata and Wikipedia and downloaded up to ten
// full-size portraits per artist, none of which any caller here
// reads. A portrait is resolved when a view asks for one.
wg.Add(1)
go func() {
defer wg.Done()
if si.artistImg != nil {
// Carries the caller's priority: interactive from
// EnsureArtistDiscography, background from the backfill.
si.artistImg.EnsureArtistRels(ctx, artist.ArtistMBID)
}
}()
wg.Wait()
// Write the artist entry into the index so indexedArtistMBIDs()
// recognises this artist as processed on subsequent builds.
// Also stores aliases and detail fields from the now-cached MB rels
// (populated by the image resolution above) for FTS search.
//
// DiscogFetched records that both LB endpoints were *asked*, not
// that they had anything to say. A transient failure still leaves
// the artist unmarked so the next run retries it — but an artist LB
// has no popularity data for (or none above indexMinPopularity,
// which is most of a long-tail library) answers empty every single
// time, and keying the mark on emptiness made those artists
// permanent candidates: the owned-artist backfill re-ran for them
// on every launch, forever, which is the bug this replaces.
if si.artistImg != nil {
gotData := rgErr == nil && recErr == nil
artistEntry := SearchIndexResult{
EntityType: "artist",
MBID: artist.ArtistMBID,
Title: artist.ArtistName,
ArtistName: artist.ArtistName,
ArtistMBID: artist.ArtistMBID,
Popularity: artist.ListenCount,
DiscogFetched: gotData,
}
if details := si.artistImg.GetArtistDetails(artist.ArtistMBID); details != nil {
artistEntry.ArtistType = details.Type
artistEntry.Country = details.Country
artistEntry.Disambiguation = details.Disambiguation
artistEntry.SortName = details.SortName
artistEntry.Aliases = details.Aliases
}
si.upsertBatch([]SearchIndexResult{artistEntry})
}
// Batch write discography results.
all := make([]SearchIndexResult, 0, len(rgs)+len(recs))
all = append(all, rgs...)
all = append(all, recs...)
for i := 0; i < len(all); i += indexBatchSize {
end := i + indexBatchSize
if end > len(all) {
end = len(all)
}
si.upsertBatch(all[i:end])
}
}
func (si *SearchIndex) fetchTopReleaseGroups(
ctx context.Context,
lb *ListenBrainzClient,
artist lbSitewideArtist,
maxCount int,
) ([]SearchIndexResult, error) {
url := fmt.Sprintf(
"%s/1/popularity/top-release-groups-for-artist/%s",
lb.baseURL, artist.ArtistMBID,
)
body, err := lb.doGet(ctx, url)
if err != nil {
si.logger.Debug("search index: top RGs failed",
"artist", artist.ArtistName,
"error", err,
)
return nil, err
}
var raw []struct {
ReleaseGroupMBID string `json:"release_group_mbid"`
TotalListenCount int `json:"total_listen_count"`
ReleaseGroup struct {
Name string `json:"name"`
Type string `json:"type"`
Date string `json:"date"`
CAAReleaseMBID string `json:"caa_release_mbid"`
} `json:"release_group"`
Artist struct {
Artists []struct {
ArtistMBID string `json:"artist_mbid"`
Name string `json:"name"`
} `json:"artists"`
} `json:"artist"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
limit := maxCount
if limit > len(raw) {
limit = len(raw)
}
results := make([]SearchIndexResult, 0, limit)
for _, r := range raw[:limit] {
if r.TotalListenCount < indexMinPopularity {
continue
}
artistName := artist.ArtistName
artistMBID := artist.ArtistMBID
if len(r.Artist.Artists) > 0 {
artistName = r.Artist.Artists[0].Name
artistMBID = r.Artist.Artists[0].ArtistMBID
}
results = append(results, SearchIndexResult{
EntityType: "release_group",
MBID: r.ReleaseGroupMBID,
Title: r.ReleaseGroup.Name,
ArtistName: artistName,
ArtistMBID: artistMBID,
Popularity: r.TotalListenCount,
PrimaryType: r.ReleaseGroup.Type,
ReleaseDate: r.ReleaseGroup.Date,
CAAReleaseMBID: r.ReleaseGroup.CAAReleaseMBID,
})
}
return results, nil
}
func (si *SearchIndex) fetchTopRecordings(
ctx context.Context,
lb *ListenBrainzClient,
artist lbSitewideArtist,
maxCount int,
) ([]SearchIndexResult, error) {
url := fmt.Sprintf(
"%s/1/popularity/top-recordings-for-artist/%s",
lb.baseURL, artist.ArtistMBID,
)
body, err := lb.doGet(ctx, url)
if err != nil {
return nil, err
}
var raw []lbTopRecordingWire
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
limit := maxCount
if limit > len(raw) {
limit = len(raw)
}
results := make([]SearchIndexResult, 0, limit)
for _, r := range raw[:limit] {
if r.TotalListenCount < indexMinPopularity {
continue
}
results = append(results, SearchIndexResult{
EntityType: "recording",
MBID: r.RecordingMBID,
Title: r.RecordingName,
ArtistName: r.ArtistName,
ArtistMBID: artist.ArtistMBID,
Popularity: r.TotalListenCount,
Duration: r.Length,
CAAReleaseMBID: r.CAAReleaseMBID,
ReleaseName: r.ReleaseName,
})
}
return results, nil
}
// ---------------------------------------------------------------------------
// Database writes
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Unified write API
// ---------------------------------------------------------------------------
//
// All writes to explore_index go through upsertBatch. There are no
// side-channel write paths — if data needs to land in the index, it
// flows through a SearchIndexResult struct that carries every field.
// The merge semantics are: non-empty incoming values replace existing
// empty values, and numeric fields use "highest wins" for popularity/
// listener_count/duration so older richer data survives refreshes.
// indexRowColumns is the full explore_index projection, and
// scanIndexRow is the only thing that reads it.
//
// There were four copies of this column list and four matching Scan
// calls, which is what makes the storage encoding dangerous: a blob
// column scanned into a string yields sixteen bytes of garbage rather
// than an error, and it would have had to be got right four times.
// dbMBID and dbEntityType do the decoding, and they refuse anything
// that is not what they expect.
var indexRowFields = []string{
"entity_type", "mbid", "title", "artist_name", "artist_mbid",
"popularity", "listener_count", "duration", "primary_type",
"secondary_types", "release_date", "total_tracks", "caa_release_mbid",
"release_name", "artist_type", "country", "disambiguation",
"sort_name", "in_library", "is_similar",
"local_artist_id", "local_release_group_id", "local_recording_id",
}
// nullableIndexRowFields are the ones a caller wants zero rather than
// NULL for.
var nullableIndexRowFields = map[string]bool{
"local_artist_id": true,
"local_release_group_id": true,
"local_recording_id": true,
}
// indexRowColumns is the projection unqualified; indexRowColumnsFor
// qualifies it with a table alias, for the joins where the other side
// also has a `title`.
var indexRowColumns = indexRowColumnsFor("")
func indexRowColumnsFor(alias string) string {
if alias != "" {
alias += "."
}
cols := make([]string, 0, len(indexRowFields))
for _, f := range indexRowFields {
if nullableIndexRowFields[f] {
cols = append(cols, "COALESCE("+alias+f+", 0)")
continue
}
cols = append(cols, alias+f)
}
return strings.Join(cols, ", ")
}
// scanIndexRow reads one indexRowColumns row into a result.
func scanIndexRow(rows *sql.Rows, r *SearchIndexResult, before ...any) error {
var (
entity dbEntityType
id dbMBID
artist dbMBID
caa dbMBID
)
dest := make([]any, 0, len(before)+len(indexRowFields))
dest = append(dest, before...)
dest = append(dest,
&entity, &id, &r.Title, &r.ArtistName, &artist,
&r.Popularity, &r.ListenerCount, &r.Duration, &r.PrimaryType,
&r.SecondaryTypes, &r.ReleaseDate, &r.TotalTracks, &caa,
&r.ReleaseName, &r.ArtistType, &r.Country, &r.Disambiguation,
&r.SortName, &r.InLibrary, &r.IsSimilar,
&r.LocalArtistID, &r.LocalReleaseGroupID, &r.LocalRecordingID,
)
if err := rows.Scan(dest...); err != nil {
return fmt.Errorf("scan index row: %w", err)
}
r.EntityType = string(entity)
r.MBID = string(id)
r.ArtistMBID = string(artist)
r.CAAReleaseMBID = string(caa)
return nil
}
// upsertIndexSQL is the single index write statement. It is kept as
// a const so assembly can prepare it once per transaction instead of
// re-parsing this large upsert for every row.
const upsertIndexSQL = `
INSERT INTO explore_index (
entity_type, mbid, title, artist_name, artist_mbid, aliases,
popularity, listener_count,
duration, caa_release_mbid, release_name,
primary_type, secondary_types, release_date, total_tracks,
artist_type, country, disambiguation, sort_name,
in_library, is_similar,
local_artist_id, local_release_group_id, local_recording_id,
discog_fetched
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?,
?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?,
?, ?,
NULLIF(?, 0), NULLIF(?, 0), NULLIF(?, 0),
?
)` + upsertIndexConflictSQL
// upsertIndexConflictSQL is the merge half of every index write, split
// out so the bulk artifact import (which inserts by SELECT rather than
// by parameter list) resolves conflicts identically instead of carrying
// a second, drifting copy of these rules.
const upsertIndexConflictSQL = `
ON CONFLICT(mbid) DO UPDATE SET
-- Title and artist info: never clobber a good value with an
-- empty one. Writers are responsible for not offering an MBID
-- as a name; AddFromCache is the path that used to.
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END,
artist_name = CASE WHEN excluded.artist_name != '' THEN excluded.artist_name ELSE artist_name END,
artist_mbid = CASE WHEN excluded.artist_mbid != x'' THEN excluded.artist_mbid ELSE artist_mbid END,
aliases = CASE WHEN excluded.aliases != '' THEN excluded.aliases ELSE aliases END,
-- Highest wins for popularity + listener_count (refreshes can go up).
popularity = CASE WHEN excluded.popularity > popularity THEN excluded.popularity ELSE popularity END,
listener_count = CASE WHEN excluded.listener_count > listener_count THEN excluded.listener_count ELSE listener_count END,
-- Non-empty wins for all other optional fields (never clobber with empty).
duration = CASE WHEN excluded.duration > 0 THEN excluded.duration ELSE duration END,
caa_release_mbid = CASE WHEN excluded.caa_release_mbid != x'' THEN excluded.caa_release_mbid ELSE caa_release_mbid END,
release_name = CASE WHEN excluded.release_name != '' THEN excluded.release_name ELSE release_name END,
primary_type = CASE WHEN excluded.primary_type != '' THEN excluded.primary_type ELSE primary_type END,
secondary_types = CASE WHEN excluded.secondary_types != '' THEN excluded.secondary_types ELSE secondary_types END,
release_date = CASE WHEN excluded.release_date != '' THEN excluded.release_date ELSE release_date END,
total_tracks = CASE WHEN excluded.total_tracks > 0 THEN excluded.total_tracks ELSE total_tracks END,
artist_type = CASE WHEN excluded.artist_type != '' THEN excluded.artist_type ELSE artist_type END,
country = CASE WHEN excluded.country != '' THEN excluded.country ELSE country END,
disambiguation = CASE WHEN excluded.disambiguation != '' THEN excluded.disambiguation ELSE disambiguation END,
sort_name = CASE WHEN excluded.sort_name != '' THEN excluded.sort_name ELSE sort_name END,
-- Flags and cross-references: non-null wins.
in_library = MAX(in_library, excluded.in_library),
is_similar = MAX(is_similar, excluded.is_similar),
discog_fetched = MAX(discog_fetched, excluded.discog_fetched),
local_artist_id = COALESCE(excluded.local_artist_id, local_artist_id),
local_release_group_id = COALESCE(excluded.local_release_group_id, local_release_group_id),
local_recording_id = COALESCE(excluded.local_recording_id, local_recording_id)
`
// upsertBatch writes a batch of SearchIndexResult entries to the index
// inside a single transaction. This is the ONE function that all
// writes go through. All fields are handled — callers don't need to
// know which columns exist for which entity types.
func (si *SearchIndex) upsertBatch(entries []SearchIndexResult) {
if len(entries) == 0 {
return
}
tx, err := si.db.BeginTx()
if err != nil {
si.logger.Warn("search index: begin tx error", "error", err)
return
}
stmt, err := tx.Prepare(upsertIndexSQL)
if err != nil {
si.logger.Warn("search index: prepare upsert error", "error", err)
_ = tx.Rollback()
return
}
defer func() { _ = stmt.Close() }()
for _, e := range entries {
if e.MBID == "" {
continue // skip entries without MBIDs — can't be looked up
}
inLib := 0
if e.InLibrary {
inLib = 1
}
isSim := 0
if e.IsSimilar {
isSim = 1
}
discogFetched := 0
if e.DiscogFetched {
discogFetched = 1
}
if _, err := stmt.Exec(
dbEntityType(e.EntityType), dbMBID(e.MBID),
e.Title, e.ArtistName, dbMBID(e.ArtistMBID), e.Aliases,
e.Popularity, e.ListenerCount,
e.Duration, dbMBID(e.CAAReleaseMBID), e.ReleaseName,
e.PrimaryType, e.SecondaryTypes, e.ReleaseDate, e.TotalTracks,
e.ArtistType, e.Country, e.Disambiguation, e.SortName,
inLib, isSim,
e.LocalArtistID, e.LocalReleaseGroupID, e.LocalRecordingID,
discogFetched,
); err != nil {
si.logger.Warn("search index: upsert error",
"mbid", e.MBID,
"error", err,
)
}
}
if err := tx.Commit(); err != nil {
si.logger.Warn("search index: commit error", "error", err)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// hasMeta reports whether a key exists in explore_index_meta.
func (si *SearchIndex) hasMeta(key string) bool {
rows, err := si.db.QueryContext(
"SELECT 1 FROM explore_index_meta WHERE key = ?", key,
)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}
// PopulateLocalCrossReferences syncs the local library into the search
// index: every MB-verified library artist, release group, and recording
// is upserted into explore_index, flagged in_library with its local id.
// Call after a library scan completes.
//
// This is the local half of the index — it makes owned content
// searchable regardless of the dump's popularity floor, using only data
// already in the library tables (no API calls). upsertBatch's conflict
// rules mean dump-seeded rows are merely tagged in_library while
// below-floor owned entities are inserted fresh (popularity 0, to be
// filled later by incremental dumps or a lazy discography fetch).
// MBID-less local content is intentionally excluded — the explore index
// is MBID-keyed, and unmatched files are served by the library search.
func (si *SearchIndex) PopulateLocalCrossReferences() {
// Clear cross-references for entities no longer owned before adding
// current ones — the upsert below only ever adds/refreshes rows for
// what's currently in the library, so removals need their own pass.
si.pruneStaleLocalCrossReferences()
entries := si.collectLibraryEntities()
if len(entries) == 0 {
si.logger.Info("library sync: no MB-verified library entities to index")
si.setMeta(localXrefReadyKey, "1")
return
}
const batchSize = 1000
for i := 0; i < len(entries); i += batchSize {
end := i + batchSize
if end > len(entries) {
end = len(entries)
}
si.upsertBatch(entries[i:end])
}
si.setMeta(localXrefReadyKey, "1")
si.logger.Info("library sync: upserted library entities into index", "count", len(entries))
}
// pruneStaleLocalCrossReferences clears in_library/local_*_id on
// explore_index rows whose local row no longer exists — e.g. an artist
// whose owned files were swapped out and removed by a rescan. The
// index upsert (upsertIndexConflictSQL) is a one-way ratchet that only
// ever sets these columns, never clears them, so this is the only place
// a removal from the library is ever reflected back into the index.
// The row itself is left in place (it may still be part of the shipped
// catalog, just no longer owned) — only the "this is mine" bookkeeping
// is cleared.
func (si *SearchIndex) pruneStaleLocalCrossReferences() {
type prune struct {
entityType string
column string
// exists is the test for "this local id still refers to
// something the user owns". It is a file test in every case -
// the version that tested the metadata table left 129 rows in
// a real catalog claiming to be owned by files that were gone.
exists string
}
for _, p := range []prune{
{"artist", "local_artist_id", `
SELECT 1 FROM artists a WHERE a.id = explore_index.local_artist_id
AND (
EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
OR EXISTS (
SELECT 1 FROM albums al
JOIN audio_files af2 ON af2.album_id = al.id
WHERE al.artist_id = a.id
)
)`},
{"release_group", "local_release_group_id", `
SELECT 1 FROM audio_files af
WHERE af.album_id = explore_index.local_release_group_id`},
{"recording", "local_recording_id", `
SELECT 1 FROM audio_files af
WHERE af.id = explore_index.local_recording_id`},
} {
result, err := si.db.ExecContext(
`UPDATE explore_index
SET in_library = 0, `+p.column+` = NULL
WHERE entity_type = ? AND `+p.column+` IS NOT NULL
AND NOT EXISTS (`+p.exists+`)`,
dbEntityType(p.entityType),
)
if err != nil {
si.logger.Warn("library sync: prune stale cross-references failed",
"entityType", p.entityType, "error", err)
continue
}
if n, _ := result.RowsAffected(); n > 0 {
si.logger.Info("library sync: cleared stale cross-references",
"entityType", p.entityType, "count", n)
}
}
}
// collectLibraryEntities builds index entries for every library artist,
// release group, and recording that carries a MusicBrainz ID. Artist
// credit strings and the primary artist MBID are resolved from the local
// artist_credit tables so no network lookup is needed.
func (si *SearchIndex) collectLibraryEntities() []SearchIndexResult {
var entries []SearchIndexResult
// Every one of these is gated on a file existing. They used to
// select straight from the metadata tables, so an artist, album or
// recording whose files were gone stayed flagged "in library" in
// the catalog until something noticed - and nothing did.
type entityQuery struct {
kind string
query string
}
queries := []entityQuery{
{"artist", `
SELECT DISTINCT a.id, a.name, a.mbid, a.name, a.mbid
FROM artists a
WHERE a.mbid IS NOT NULL AND a.mbid != ''
AND (
EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
OR EXISTS (
SELECT 1 FROM albums al
JOIN audio_files af2 ON af2.album_id = al.id
WHERE al.artist_id = a.id
)
)`},
{"release_group", `
SELECT DISTINCT al.id, al.name, al.mbid, al.artist_credit,
COALESCE(ar.mbid, '')
FROM albums al
JOIN audio_files af ON af.album_id = al.id
LEFT JOIN artists ar ON ar.id = al.artist_id
WHERE al.mbid IS NOT NULL AND al.mbid != ''`},
{"recording", `
SELECT af.id, af.title, af.recording_mbid, af.artist_credit,
COALESCE(ar.mbid, '')
FROM audio_files af
LEFT JOIN artists ar ON ar.id = af.artist_id
WHERE af.recording_mbid IS NOT NULL AND af.recording_mbid != ''`},
}
for _, eq := range queries {
rows, err := si.db.QueryContext(eq.query)
if err != nil {
si.logger.Warn("library sync: query failed", "kind", eq.kind, "error", err)
continue
}
for rows.Next() {
var (
id int64
name, mbid, credit, artistMB string
)
if err := rows.Scan(&id, &name, &mbid, &credit, &artistMB); err != nil {
continue
}
entry := SearchIndexResult{
EntityType: eq.kind,
MBID: mbid,
Title: name,
ArtistName: credit,
ArtistMBID: artistMB,
InLibrary: true,
}
switch eq.kind {
case "artist":
entry.LocalArtistID = id
case "release_group":
entry.LocalReleaseGroupID = id
case "recording":
// The local id of a "recording" is the file's, which is
// what every caller wants: it is the thing that plays.
entry.LocalRecordingID = id
}
entries = append(entries, entry)
}
_ = rows.Close()
}
return entries
}
// storeSimilarArtists persists the similar artist relationships
// for a source artist into the similar_artist_map table.
func (si *SearchIndex) storeSimilarArtists(sourceMBID string, similar []lbSimilarArtistWire) {
if len(similar) == 0 {
return
}
tx, err := si.db.BeginTx()
if err != nil {
return
}
defer func() { _ = tx.Rollback() }()
// Clear existing entries for this source to avoid stale data.
_, _ = tx.Exec(
"DELETE FROM similar_artist_map WHERE source_artist_mbid = ?",
sourceMBID,
)
for _, s := range similar {
_, _ = tx.Exec(`
INSERT OR IGNORE INTO similar_artist_map
(source_artist_mbid, similar_artist_mbid, similar_artist_name, score)
VALUES (?, ?, ?, ?)
`, sourceMBID, s.ArtistMBID, s.Name, s.Score)
}
_ = tx.Commit()
}
// PopularityData holds both listen count and listener count for a
// single entity. Used by BackfillPopularity and the popularity
// pipeline to pass both metrics together.
type PopularityData struct {
ListenCount int
ListenerCount int
}
// BackfillPopularity writes LB popularity values back to the index
// for MBIDs that already exist. Called after LB API responses so
// subsequent searches use the index instead of re-fetching from LB.
func (si *SearchIndex) BackfillPopularity(updates map[string]PopularityData) {
if len(updates) == 0 {
return
}
tx, err := si.db.BeginTx()
if err != nil {
return
}
defer func() { _ = tx.Rollback() }()
for mbid, data := range updates {
if data.ListenCount <= 0 {
continue
}
_, _ = tx.Exec(
`UPDATE explore_index
SET popularity = CASE WHEN ? > popularity THEN ? ELSE popularity END,
listener_count = CASE WHEN ? > listener_count THEN ? ELSE listener_count END
WHERE mbid = ?`,
data.ListenCount, data.ListenCount,
data.ListenerCount, data.ListenerCount,
dbMBID(mbid),
)
}
_ = tx.Commit()
}
// BackfillPopularitySimple is a convenience wrapper for callers that
// only have listen counts (no listener count).
func (si *SearchIndex) BackfillPopularitySimple(updates map[string]int) {
if len(updates) == 0 {
return
}
full := make(map[string]PopularityData, len(updates))
for mbid, pop := range updates {
full[mbid] = PopularityData{ListenCount: pop}
}
si.BackfillPopularity(full)
}
// GetSimilarityScores returns the highest similarity score for each
// MBID that appears in similar_artist_map as a similar artist.
// Returns a map of mbid → max similarity score.
func (si *SearchIndex) GetSimilarityScores(mbids []string) map[string]int {
if len(mbids) == 0 {
return nil
}
placeholders := make([]string, len(mbids))
args := make([]any, len(mbids))
for i, m := range mbids {
placeholders[i] = "?"
args[i] = m
}
query := "SELECT similar_artist_mbid, MAX(score) FROM similar_artist_map WHERE similar_artist_mbid IN (" +
strings.Join(
placeholders,
",",
) + ") GROUP BY similar_artist_mbid"
rows, err := si.db.QueryContext(query, args...)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
result := make(map[string]int, len(mbids))
for rows.Next() {
var (
mbid string
score int
)
if err := rows.Scan(&mbid, &score); err == nil {
result[mbid] = score
}
}
return result
}
// InvalidateDiscographies clears the dump-import completion marker so
// the next StartBuild re-runs the full dump import.
func (si *SearchIndex) InvalidateDiscographies() {
_, _ = si.db.ExecContext(
"DELETE FROM explore_index_meta WHERE key = 'dump_import_done'",
)
}
func (si *SearchIndex) setMeta(key, value string) {
if _, err := si.db.ExecContext(
"INSERT OR REPLACE INTO explore_index_meta (key, value) VALUES (?, ?)",
key, value,
); err != nil {
si.logger.Warn("search index: set meta error", "key", key, "error", err)
}
}
// deleteMeta removes a key from explore_index_meta, e.g. to invalidate a
// "ready" marker so the next launch re-runs a gated build step.
func (si *SearchIndex) deleteMeta(key string) {
if _, err := si.db.ExecContext(
"DELETE FROM explore_index_meta WHERE key = ?", key,
); err != nil {
si.logger.Warn("search index: delete meta error", "key", key, "error", err)
}
}
// MarkReadyIfPopulated sets the index as ready for querying if it
// already contains data from a previous build. Called eagerly at
// service creation so the index is queryable before StartBuild runs.
func (si *SearchIndex) MarkReadyIfPopulated() {
rows, err := si.db.QueryContext("SELECT COUNT(*) FROM explore_index")
if err != nil {
return
}
var count int
if rows.Next() {
_ = rows.Scan(&count)
}
// Release the connection before any further query: under a
// single-connection pool (tests) a nested query while these rows are
// open would deadlock.
_ = rows.Close()
if count == 0 {
return
}
// Trust a champion index built in a previous run; otherwise build it
// below now that the main index is known-ready.
championBuilt := si.hasMeta(championBuiltKey)
si.mu.Lock()
si.ready = true
si.championReady = championBuilt
si.mu.Unlock()
si.logger.Info("search index: using existing index", "entries", count)
// Becoming ready is a change the UI has to see, and this was the one
// path that mutated the status without saying so — the 3 s ticker
// carried it, invisibly, which is why removing the ticker without
// this line would have left the settings page reading "not ready"
// over a fully built index.
si.emitStatus()
if !championBuilt {
si.scheduleChampionRebuild()
}
}