Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2905 lines
84 KiB
Go
2905 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{}
|
|
|
|
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()
|
|
}
|
|
}
|