diff --git a/backend/app.go b/backend/app.go index b72d72f..0bcb8e9 100644 --- a/backend/app.go +++ b/backend/app.go @@ -189,6 +189,8 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { yj.library.SetContext(ctx) yj.playlist.SetContext(ctx) yj.playlist.EnsureDefaultPlaylist() + // Recover playlists that lost tracks from a pre-fix FullRescan. + go yj.playlist.RepopulateFromM3U() // Initialize speaker hardware (player struct created in // NewYellowJacketApp for Wails binding registration). @@ -230,11 +232,22 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { // Wire scan hooks so the playlist service can resolve // phantom tracks after each library scan completes. yj.library.SetScanHooks(library.ScanHooks{ - ResolvePhantoms: yj.playlist.ResolvePhantomTracksAfterScan, + RepopulatePlaylists: yj.playlist.RepopulateFromM3U, + ResolvePhantoms: yj.playlist.ResolvePhantomTracksAfterScan, OnAllScansComplete: func() { - // Only index artists that are new since the last - // build — don't re-run the full tier pipeline. + // Index new library artists (blocks until done). yj.explore.IndexNewArtists() + yj.explore.WaitForIndexIdle() + + // Populate local_*_id cross-reference columns on + // explore_index so "is this in my library?" is O(1). + yj.explore.PopulateLocalCrossReferences() + + // Always start the full build — it's incremental and + // will skip tiers that are already fresh. This ensures + // sitewide + similar artist tiers run even if the index + // already has library data. + yj.explore.StartIndexBuild() }, }) @@ -301,6 +314,13 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool { w, h := wailsruntime.WindowGetSize(ctx) + yj.logger.Info("OnBeforeClose: saving window state", + "width", w, + "height", h, + "accentColor", yj.appConfig.Theme.AccentColor, + "backgroundShade", yj.appConfig.Theme.BackgroundShade, + ) + yj.appConfig.Window.Width = w yj.appConfig.Window.Height = h diff --git a/backend/config/config.go b/backend/config/config.go index 94c834d..a44474a 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -26,6 +26,7 @@ type Config struct { ctx context.Context logger *slog.Logger filePath string // required + loaded bool // true once Load() succeeds Library *library.Config `toml:"Library"` Theme *theme.Config `toml:"Theme"` Window *WindowConfig `toml:"Window"` @@ -142,12 +143,22 @@ func (c *Config) Load() error { } c.logger.Debug("loaded config file", "file", c.filePath) + c.loaded = true return nil } -// Save writes the config to disk. +// Save writes the config to disk. Refuses to write if the config +// was never successfully loaded — prevents overwriting user config +// with defaults during abnormal startup/shutdown sequences. func (c *Config) Save() error { + if !c.loaded { + // Allow the initial save when the file doesn't exist yet. + if _, err := os.Stat(c.filePath); err == nil { + return fmt.Errorf("refusing to save: config not loaded from disk") + } + } + if err := c.Validate(); err != nil { return fmt.Errorf("invalid config: %w", err) } diff --git a/backend/database/database.go b/backend/database/database.go index a8db68c..283f5a2 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -417,10 +417,508 @@ func runMigrations( } } + if version < 18 { + if err := migration18TrackCoverArt( + ctx, db, logger, + ); err != nil { + return err + } + } + + if version < 19 { + if err := migration19TrackMBIDs( + ctx, db, logger, + ); err != nil { + return err + } + } + + if version < 20 { + if err := migration20TrackRecordingMBID( + ctx, db, logger, + ); err != nil { + return err + } + } + + if version < 21 { //nolint:mnd + logger.Info("applying migration 21: explore_index mbid-only index") + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_explore_index_mbid_only + ON explore_index(mbid) + `); err != nil { + return fmt.Errorf("migration 21: create mbid-only index: %w", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 21", + ); err != nil { + return fmt.Errorf("migration 21: set user_version: %w", err) + } + } + + if version < 22 { //nolint:mnd + logger.Info("applying migration 22: replace composite index with UNIQUE(mbid)") + + // Remove any rows with empty MBIDs — they can't be looked up + // and would violate the new UNIQUE(mbid) constraint. + if _, err := db.ExecContext(ctx, ` + DELETE FROM explore_index WHERE mbid = '' + `); err != nil { + return fmt.Errorf("migration 22: delete empty mbids: %w", err) + } + + // Drop the over-engineered composite — MBIDs are globally + // unique, so entity_type in the key adds nothing. + if _, err := db.ExecContext(ctx, ` + DROP INDEX IF EXISTS idx_explore_index_mbid + `); err != nil { + return fmt.Errorf("migration 22: drop composite index: %w", err) + } + + // Drop the plain index from migration 21 and recreate as UNIQUE. + if _, err := db.ExecContext(ctx, ` + DROP INDEX IF EXISTS idx_explore_index_mbid_only + `); err != nil { + return fmt.Errorf("migration 22: drop plain mbid index: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_explore_index_mbid_only + ON explore_index(mbid) + `); err != nil { + return fmt.Errorf("migration 22: create unique mbid index: %w", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 22", + ); err != nil { + return fmt.Errorf("migration 22: set user_version: %w", err) + } + } + + if version < 23 { //nolint:mnd + logger.Info("applying migration 23: search_clicks table") + + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS search_clicks ( + query TEXT NOT NULL, + entity_mbid TEXT NOT NULL, + entity_type TEXT NOT NULL, + click_count INTEGER NOT NULL DEFAULT 1, + last_clicked DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (query, entity_mbid) + ) + `); err != nil { + return fmt.Errorf("migration 23: create search_clicks: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_search_clicks_query + ON search_clicks(query) + `); err != nil { + return fmt.Errorf("migration 23: create query index: %w", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 23", + ); err != nil { + return fmt.Errorf("migration 23: set user_version: %w", err) + } + } + + if version < 24 { //nolint:mnd + logger.Info("applying migration 24: explore_index listener_count + duration columns") + + if _, err := db.ExecContext(ctx, ` + ALTER TABLE explore_index ADD COLUMN listener_count INTEGER NOT NULL DEFAULT 0 + `); err != nil { + // Column may already exist from a partial migration. + if !strings.Contains(err.Error(), "duplicate column") { + return fmt.Errorf("migration 24: add listener_count: %w", err) + } + } + + if _, err := db.ExecContext(ctx, ` + ALTER TABLE explore_index ADD COLUMN duration INTEGER NOT NULL DEFAULT 0 + `); err != nil { + if !strings.Contains(err.Error(), "duplicate column") { + return fmt.Errorf("migration 24: add duration: %w", err) + } + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 24", + ); err != nil { + return fmt.Errorf("migration 24: set user_version: %w", err) + } + } + + if version < 25 { //nolint:mnd + logger.Info("applying migration 25: explore_index duration column") + + if _, err := db.ExecContext(ctx, ` + ALTER TABLE explore_index ADD COLUMN duration INTEGER NOT NULL DEFAULT 0 + `); err != nil { + if !strings.Contains(err.Error(), "duplicate column") { + return fmt.Errorf("migration 25: add duration: %w", err) + } + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 25", + ); err != nil { + return fmt.Errorf("migration 25: set user_version: %w", err) + } + } + + if version < 26 { //nolint:mnd + logger.Info("applying migration 26: comprehensive explore schema overhaul") + + // Nuke the existing index — we're changing the schema enough + // that a clean rebuild is simpler than trying to migrate in place. + if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS explore_index_fts`); err != nil { + return fmt.Errorf("migration 26: drop fts: %w", err) + } + + if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS explore_index`); err != nil { + return fmt.Errorf("migration 26: drop explore_index: %w", err) + } + + // Create the new explore_index with all typed columns. + // No more extra_json — every field that matters has its own column. + if _, err := db.ExecContext(ctx, ` + CREATE TABLE explore_index ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + mbid TEXT NOT NULL, + title TEXT NOT NULL, + artist_name TEXT NOT NULL, + artist_mbid TEXT NOT NULL, + aliases TEXT NOT NULL DEFAULT '', + + -- Popularity signals (from LB popularity API, uncapped). + popularity INTEGER NOT NULL DEFAULT 0, + listener_count INTEGER NOT NULL DEFAULT 0, + + -- Recording-specific fields. + duration INTEGER NOT NULL DEFAULT 0, + caa_release_mbid TEXT NOT NULL DEFAULT '', + release_name TEXT NOT NULL DEFAULT '', + + -- Release-group-specific fields. + primary_type TEXT NOT NULL DEFAULT '', + secondary_types TEXT NOT NULL DEFAULT '', + release_date TEXT NOT NULL DEFAULT '', + + -- Artist-specific fields. + artist_type TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT '', + disambiguation TEXT NOT NULL DEFAULT '', + sort_name TEXT NOT NULL DEFAULT '', + + -- Personalization flags. + in_library INTEGER NOT NULL DEFAULT 0, + is_similar INTEGER NOT NULL DEFAULT 0, + + -- Cross-reference to local library tables. NULL when the + -- entity has no corresponding row in the library. + local_artist_id INTEGER, + local_release_group_id INTEGER, + local_recording_id INTEGER, + + -- Set to 1 by indexOneArtist after fetching the full + -- discography (release groups + recordings). Used by + -- indexedArtistMBIDs() so the AddFromCache organic-growth + -- path doesn't shadow artists from later tier 2/3 runs. + discog_fetched INTEGER NOT NULL DEFAULT 0, + + -- Schema version — lets us mark rows as stale after schema changes. + schema_version INTEGER NOT NULL DEFAULT 1, + + UNIQUE(mbid) + ) + `); err != nil { + return fmt.Errorf("migration 26: create explore_index: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX idx_explore_index_artist_mbid + ON explore_index(artist_mbid, entity_type, popularity DESC) + `); err != nil { + return fmt.Errorf("migration 26: create artist_mbid index: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX idx_explore_index_entity_pop + ON explore_index(entity_type, popularity DESC) + `); err != nil { + return fmt.Errorf("migration 26: create entity_pop index: %w", err) + } + + // FTS5 virtual table for text search. + if _, err := db.ExecContext(ctx, ` + CREATE VIRTUAL TABLE explore_index_fts USING fts5( + title, artist_name, aliases, + content='explore_index', + content_rowid='id' + ) + `); err != nil { + return fmt.Errorf("migration 26: create fts: %w", err) + } + + // Triggers to keep FTS in sync with the main table. + if _, err := db.ExecContext(ctx, ` + CREATE TRIGGER explore_index_ai AFTER INSERT ON explore_index BEGIN + INSERT INTO explore_index_fts(rowid, title, artist_name, aliases) + VALUES (new.id, new.title, new.artist_name, new.aliases); + END + `); err != nil { + return fmt.Errorf("migration 26: create ai trigger: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE TRIGGER explore_index_ad AFTER DELETE ON explore_index BEGIN + INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases) + VALUES ('delete', old.id, old.title, old.artist_name, old.aliases); + END + `); err != nil { + return fmt.Errorf("migration 26: create ad trigger: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN + INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases) + VALUES ('delete', old.id, old.title, old.artist_name, old.aliases); + INSERT INTO explore_index_fts(rowid, title, artist_name, aliases) + VALUES (new.id, new.title, new.artist_name, new.aliases); + END + `); err != nil { + return fmt.Errorf("migration 26: create au trigger: %w", err) + } + + // Clear the tier metadata so the next build repopulates everything. + if _, err := db.ExecContext(ctx, `DELETE FROM explore_index_meta`); err != nil { + return fmt.Errorf("migration 26: clear meta: %w", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 26", + ); err != nil { + return fmt.Errorf("migration 26: set user_version: %w", err) + } + } + + if version < 27 { //nolint:mnd + logger.Info("applying migration 27: split explore_cache into http_cache and artist_metadata") + + // Create the new tables (no-op if schemas/*.sql already created them). + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS artist_metadata ( + mbid TEXT NOT NULL, + source TEXT NOT NULL, + data BLOB NOT NULL, + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (mbid, source) + ) + `); err != nil { + return fmt.Errorf("migration 27: create artist_metadata: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid + ON artist_metadata(mbid) + `); err != nil { + return fmt.Errorf("migration 27: create artist_metadata index: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS http_cache ( + url_key TEXT PRIMARY KEY, + response BLOB NOT NULL, + expires_at DATETIME NOT NULL, + entity_mbid TEXT NOT NULL DEFAULT '', + entity_type TEXT NOT NULL DEFAULT '' + ) + `); err != nil { + return fmt.Errorf("migration 27: create http_cache: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_http_cache_expires + ON http_cache(expires_at) + `); err != nil { + return fmt.Errorf("migration 27: create http_cache index: %w", err) + } + + // Only migrate existing data if explore_cache exists (not a fresh install). + var exploreCacheExists bool + { + row, err := db.QueryContext(ctx, + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='explore_cache'", + ) + if err == nil { + if row.Next() { + exploreCacheExists = true + } + + _ = row.Close() + } + } + + if exploreCacheExists { + // Migrate long-lived sources into artist_metadata. + for _, src := range []string{"audiodb", "fanart", "wikidata-p18", "wikipedia-lead"} { + if _, err := db.ExecContext(ctx, ` + INSERT OR IGNORE INTO artist_metadata (mbid, source, data, fetched_at) + SELECT substr(url_key, ?+1), ?, response, COALESCE(expires_at, CURRENT_TIMESTAMP) + FROM explore_cache + WHERE url_key LIKE ? + `, len(src)+1, src, src+":%"); err != nil { + return fmt.Errorf("migration 27: migrate %s: %w", src, err) + } + } + + // Migrate remaining (short-lived) entries into http_cache. + if _, err := db.ExecContext(ctx, ` + INSERT OR IGNORE INTO http_cache (url_key, response, expires_at, entity_mbid, entity_type) + SELECT url_key, response, expires_at, + COALESCE(mbid, ''), COALESCE(entity_type, '') + FROM explore_cache + `); err != nil { + return fmt.Errorf("migration 27: migrate http_cache: %w", err) + } + + // Drop the old table. + if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS explore_cache`); err != nil { + return fmt.Errorf("migration 27: drop explore_cache: %w", err) + } + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 27", + ); err != nil { + return fmt.Errorf("migration 27: set user_version: %w", err) + } + } + + if version < 28 { //nolint:mnd + logger.Info("applying migration 28: repair broken similar_artist_map data from multi-seed labs bug") + + // The multi-seed POST form of the labs similar-artists endpoint + // returns mis-grouped results — each seed ends up with a random + // subset of the shared result pool (1-2 artists for most seeds, + // hundreds for a few). Clear the bad rows and invalidate the + // tier4 timestamp so the next index build refetches per-seed. + if _, err := db.ExecContext(ctx, + "DELETE FROM similar_artist_map", + ); err != nil { + return fmt.Errorf("migration 28: clear similar_artist_map: %w", err) + } + + // Invalidate the tier4 build timestamp so the next startup + // triggers a Tier 4 rebuild. Also clear is_similar markers + // so they get recomputed. + if _, err := db.ExecContext(ctx, + "DELETE FROM explore_index_meta WHERE key = 'tier4_built'", + ); err != nil { + // Not fatal — the meta table might not exist yet. + logger.Warn("migration 28: clear tier4_built failed (ok on fresh install)", "error", err) + } + + if _, err := db.ExecContext(ctx, + "UPDATE explore_index SET is_similar = 0 WHERE is_similar = 1", + ); err != nil { + // Not fatal — explore_index might not exist yet on a + // fresh install where migration 26 just ran. + logger.Warn("migration 28: clear is_similar failed", "error", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 28", + ); err != nil { + return fmt.Errorf("migration 28: set user_version: %w", err) + } + } + + if version < 29 { //nolint:mnd + logger.Info("applying migration 29: discog_fetched column to track full indexer pipeline coverage") + + // Add a discog_fetched column to explore_index. When set to 1 + // on an artist row, the indexer's fetchTopRecordings/ + // fetchTopReleaseGroups pipeline has run for that artist. + // AddFromCache (the frontend-visit organic-growth path) does + // NOT set this flag — it only writes the artist row plus + // browse-result release groups, so recordings are missing. + // + // indexedArtistMBIDs() filters by discog_fetched=1, so artists + // who only got their row from AddFromCache will still be + // processed by Tier 2/3 and have their full discography fetched + // (including recordings). + if _, err := db.ExecContext(ctx, ` + ALTER TABLE explore_index + ADD COLUMN discog_fetched INTEGER NOT NULL DEFAULT 0 + `); err != nil { + // May fail if migration runs against a fresh schema (column + // will be created by the schema file instead). Don't bail. + logger.Warn("migration 29: add discog_fetched column failed (ok if fresh)", "error", err) + } + + // Backfill: any artist with at least 5 recordings was almost + // certainly hit by fetchTopRecordings (the floor is 5). Use + // this as a heuristic to mark existing data as "discog fetched" + // so the migration is non-disruptive — only the broken + // AddFromCache-only artists get re-indexed. + if _, err := db.ExecContext(ctx, ` + UPDATE explore_index + SET discog_fetched = 1 + WHERE entity_type = 'artist' + AND mbid IN ( + SELECT artist_mbid + FROM explore_index + WHERE entity_type = 'recording' + GROUP BY artist_mbid + HAVING COUNT(*) >= 5 + ) + `); err != nil { + logger.Warn("migration 29: backfill discog_fetched failed", "error", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 29", + ); err != nil { + return fmt.Errorf("migration 29: set user_version: %w", err) + } + } + + if version < 30 { //nolint:mnd + logger.Info("applying migration 30: invalidate MB browse-releases cache for recording MBID fix") + + // Earlier versions of convertRelease used the MusicBrainz + // track MBID instead of the recording MBID for MBTrack.MBID. + // Tracks and recordings have distinct MBIDs in MB, and the + // local library tags files with the recording MBID, so the + // library-status indicator on album detail pages was always + // showing "not in library" for cached results. Clear the + // http_cache entries for MB browse-releases so the next + // visit refetches with the fixed converter. + if _, err := db.ExecContext(ctx, + "DELETE FROM http_cache WHERE url_key LIKE 'mb:browse:releases:%'", + ); err != nil { + // Not fatal — cache might not exist on fresh installs. + logger.Warn("migration 30: clear browse-releases cache failed", "error", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 30", + ); err != nil { + return fmt.Errorf("migration 30: set user_version: %w", err) + } + } + return nil } - -// migration2BasenameAndFTS adds the basename column to audio_files, // backfills it from file_path, creates the basename index, and // populates the FTS5 search_index table. func migration2BasenameAndFTS( @@ -1878,6 +2376,78 @@ func migration17SimilarArtistMap( return nil } +// migration18TrackCoverArt recreates the track_metadata VIEW to +// include cover_art_path via a JOIN to the cover_art table. +func migration18TrackCoverArt( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 18: track_metadata cover_art_path") + + if _, err := db.ExecContext( + ctx, "DROP VIEW IF EXISTS track_metadata", + ); err != nil { + return fmt.Errorf("migration 18: drop view: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE VIEW IF NOT EXISTS track_metadata AS + SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size, + af.library_id, + af.play_count, + af.last_played, + COALESCE(ca.file_path, '') AS cover_art_path + FROM audio_files af + LEFT JOIN recordings r ON af.recording_id = r.id + LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id + LEFT JOIN file_types ft ON af.file_type_id = ft.id + `); err != nil { + return fmt.Errorf("migration 18: create view: %w", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 18", + ); err != nil { + return fmt.Errorf("could not set user_version to 18: %w", err) + } + + logger.Info("migration 18 complete") + + return nil +} + // readLibraryDirFromTOML reads the TOML config file and returns // the Library.DirectoryPath value, or "" if not configured. func readLibraryDirFromTOML(logger *slog.Logger) string { @@ -2000,3 +2570,169 @@ func removeLibraryDirFromTOML(logger *slog.Logger) { "path", configPath, ) } + +// migration19TrackMBIDs recreates the track_metadata VIEW to include +// artist_mbid and release_group_mbid columns via the relational +// chain: recording → artist_credit → artist_credit_artist → artist. +func migration19TrackMBIDs( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 19: track_metadata MBID columns") + + if _, err := db.ExecContext( + ctx, "DROP VIEW IF EXISTS track_metadata", + ); err != nil { + return fmt.Errorf("migration 19: drop view: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE VIEW IF NOT EXISTS track_metadata AS + SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size, + af.library_id, + af.play_count, + af.last_played, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid + FROM audio_files af + LEFT JOIN recordings r ON af.recording_id = r.id + LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id + LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id + LEFT JOIN artists a ON a.id = aca.artist_id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id + LEFT JOIN file_types ft ON af.file_type_id = ft.id + `); err != nil { + return fmt.Errorf("migration 19: create view: %w", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 19", + ); err != nil { + return fmt.Errorf("could not set user_version to 19: %w", err) + } + + logger.Info("migration 19 complete") + + return nil +} + +// migration20TrackRecordingMBID recreates the track_metadata VIEW to +// add the recording_mbid column (missed in migration 19). +func migration20TrackRecordingMBID( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 20: track_metadata recording_mbid") + + if _, err := db.ExecContext( + ctx, "DROP VIEW IF EXISTS track_metadata", + ); err != nil { + return fmt.Errorf("migration 20: drop view: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE VIEW IF NOT EXISTS track_metadata AS + SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size, + af.library_id, + af.play_count, + af.last_played, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid + FROM audio_files af + LEFT JOIN recordings r ON af.recording_id = r.id + LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id + LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id + LEFT JOIN artists a ON a.id = aca.artist_id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id + LEFT JOIN file_types ft ON af.file_type_id = ft.id + `); err != nil { + return fmt.Errorf("migration 20: create view: %w", err) + } + + // Purge stale ListenBrainz top-recordings cache entries that + // were written before the caaReleaseMbid field was added to + // the LBTopRecording struct. Without this, cached entries + // render without cover art thumbnails in the top tracks section. + if _, err := db.ExecContext(ctx, + "DELETE FROM explore_cache WHERE url_key LIKE 'lb:top-recordings:%'", + ); err != nil { + logger.Warn("migration 20: could not purge stale top-recordings cache", "err", err) + // Non-fatal — entries will expire naturally via TTL. + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 20", + ); err != nil { + return fmt.Errorf("could not set user_version to 20: %w", err) + } + + logger.Info("migration 20 complete") + + return nil +} diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index 6337a8c..0f4ae45 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -62,10 +62,15 @@ SELECT COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist, COALESCE(rg.name, '') AS album, - COALESCE(ca.file_path, '') AS cover_art_path + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id @@ -97,12 +102,19 @@ SELECT af.bitrate, af.file_size, af.play_count, - af.last_played + af.last_played, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM audio_files af JOIN recordings r ON af.recording_id = r.id JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id LEFT JOIN file_types ft ON af.file_type_id = ft.id; -- name: SearchAudioFilesByBasename :many @@ -125,7 +137,7 @@ WHERE af.basename = ? LIMIT ?; -- name: LookupTrackMetaByPaths :many -SELECT id, file_path, title, artist_name +SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid FROM track_metadata WHERE file_path IN (sqlc.slice('paths')); @@ -163,12 +175,19 @@ SELECT af.bitrate, af.file_size, af.play_count, - af.last_played + af.last_played, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM audio_files af JOIN recordings r ON af.recording_id = r.id JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE af.library_id = ?; @@ -195,11 +214,16 @@ SELECT af.bit_depth, af.channels, af.bitrate, - af.file_size + af.file_size, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM release_group_recordings rgr JOIN recordings r ON rgr.recording_id = r.id JOIN audio_files af ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE rgr.release_group_id = ? @@ -228,11 +252,16 @@ SELECT af.bit_depth, af.channels, af.bitrate, - af.file_size + af.file_size, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM release_group_recordings rgr JOIN recordings r ON rgr.recording_id = r.id JOIN audio_files af ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE rgr.release_group_id = ? AND af.library_id = ? diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql index 4107f7a..e2afecd 100644 --- a/backend/database/sql/queries/playlists.sql +++ b/backend/database/sql/queries/playlists.sql @@ -53,11 +53,16 @@ SELECT COALESCE(ac.text, pt.phantom_artist, '') AS artist, COALESCE(rg.name, pt.phantom_album, '') AS album, COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, - CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom + CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM playlist_tracks pt LEFT JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings @@ -80,11 +85,16 @@ SELECT COALESCE(ac.text, pt.phantom_artist, '') AS artist, COALESCE(rg.name, pt.phantom_album, '') AS album, COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, - CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom + CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM playlist_tracks pt LEFT JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings diff --git a/backend/database/sql/queries/queue.sql b/backend/database/sql/queries/queue.sql index fee35d2..0f9e389 100644 --- a/backend/database/sql/queries/queue.sql +++ b/backend/database/sql/queries/queue.sql @@ -15,11 +15,25 @@ WHERE id = 1; -- name: GetQueueTracks :many SELECT qt.id, qt.audio_file_id, qt.position, af.file_path, COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM queue_tracks qt JOIN audio_files af ON qt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id ORDER BY qt.position; -- name: GetQueueTrackCount :one diff --git a/backend/database/sql/schemas/artist_metadata.sql b/backend/database/sql/schemas/artist_metadata.sql new file mode 100644 index 0000000..1941db7 --- /dev/null +++ b/backend/database/sql/schemas/artist_metadata.sql @@ -0,0 +1,12 @@ +-- Long-lived artist enrichment data keyed by MBID and source. +-- Sources: audiodb, fanart, wikidata-p18, wikipedia-lead, mb:artist-rels. +-- No TTL — this data changes very rarely and is the backing store for +-- the artist detail page. +CREATE TABLE IF NOT EXISTS artist_metadata ( + mbid TEXT NOT NULL, + source TEXT NOT NULL, + data BLOB NOT NULL, + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (mbid, source) +); +CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid ON artist_metadata(mbid); diff --git a/backend/database/sql/schemas/explore_cache.sql b/backend/database/sql/schemas/explore_cache.sql deleted file mode 100644 index f28643d..0000000 --- a/backend/database/sql/schemas/explore_cache.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS explore_cache ( - url_key TEXT PRIMARY KEY, - response TEXT NOT NULL, - mbid TEXT, - entity_type TEXT, - expires_at DATETIME NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX IF NOT EXISTS idx_explore_cache_expires ON explore_cache(expires_at); -CREATE INDEX IF NOT EXISTS idx_explore_cache_mbid ON explore_cache(mbid); diff --git a/backend/database/sql/schemas/http_cache.sql b/backend/database/sql/schemas/http_cache.sql new file mode 100644 index 0000000..4e42333 --- /dev/null +++ b/backend/database/sql/schemas/http_cache.sql @@ -0,0 +1,11 @@ +-- Short-lived HTTP response cache (search results, MB/LB lookups, etc). +-- For long-lived enrichment data keyed by MBID, see artist_metadata.sql. +CREATE TABLE IF NOT EXISTS http_cache ( + url_key TEXT PRIMARY KEY, + response BLOB NOT NULL, + expires_at DATETIME NOT NULL, + entity_mbid TEXT NOT NULL DEFAULT '', + entity_type TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_http_cache_expires ON http_cache(expires_at); +CREATE INDEX IF NOT EXISTS idx_http_cache_mbid ON http_cache(entity_mbid); diff --git a/backend/database/sql/schemas/recordings.sql b/backend/database/sql/schemas/recordings.sql index 78bf85b..58c66cd 100644 --- a/backend/database/sql/schemas/recordings.sql +++ b/backend/database/sql/schemas/recordings.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS recordings ( composer TEXT, lyrics TEXT, comment TEXT, + mbid TEXT, FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id) ); diff --git a/backend/database/sql/schemas/track_metadata_view.sql b/backend/database/sql/schemas/track_metadata_view.sql index 11f38b1..048efdd 100644 --- a/backend/database/sql/schemas/track_metadata_view.sql +++ b/backend/database/sql/schemas/track_metadata_view.sql @@ -25,10 +25,16 @@ SELECT af.file_size, af.library_id, af.play_count, - af.last_played + af.last_played, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id @@ -36,4 +42,5 @@ LEFT JOIN ( GROUP BY recording_id ) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id LEFT JOIN file_types ft ON af.file_type_id = ft.id; diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index 40dd1b8..e7fe1a3 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -259,12 +259,19 @@ SELECT af.bitrate, af.file_size, af.play_count, - af.last_played + af.last_played, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM audio_files af JOIN recordings r ON af.recording_id = r.id JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id LEFT JOIN file_types ft ON af.file_type_id = ft.id ` @@ -287,6 +294,10 @@ type GetAllTracksWithFullMetadataRow struct { FileSize int64 PlayCount int64 LastPlayed sql.NullTime + CoverArtPath string + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTracksWithFullMetadataRow, error) { @@ -317,6 +328,10 @@ func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTra &i.FileSize, &i.PlayCount, &i.LastPlayed, + &i.CoverArtPath, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ); err != nil { return nil, err } @@ -356,12 +371,19 @@ SELECT af.bitrate, af.file_size, af.play_count, - af.last_played + af.last_played, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM audio_files af JOIN recordings r ON af.recording_id = r.id JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE af.library_id = ? ` @@ -385,6 +407,10 @@ type GetAllTracksWithFullMetadataByLibraryRow struct { FileSize int64 PlayCount int64 LastPlayed sql.NullTime + CoverArtPath string + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) GetAllTracksWithFullMetadataByLibrary(ctx context.Context, libraryID int64) ([]GetAllTracksWithFullMetadataByLibraryRow, error) { @@ -415,6 +441,10 @@ func (q *Queries) GetAllTracksWithFullMetadataByLibrary(ctx context.Context, lib &i.FileSize, &i.PlayCount, &i.LastPlayed, + &i.CoverArtPath, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ); err != nil { return nil, err } @@ -548,11 +578,16 @@ SELECT af.bit_depth, af.channels, af.bitrate, - af.file_size + af.file_size, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM release_group_recordings rgr JOIN recordings r ON rgr.recording_id = r.id JOIN audio_files af ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE rgr.release_group_id = ? @@ -576,6 +611,9 @@ type GetAudioFilesByReleaseGroupRow struct { Channels int64 Bitrate int64 FileSize int64 + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupID int64) ([]GetAudioFilesByReleaseGroupRow, error) { @@ -604,6 +642,9 @@ func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupI &i.Channels, &i.Bitrate, &i.FileSize, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ); err != nil { return nil, err } @@ -641,11 +682,16 @@ SELECT af.bit_depth, af.channels, af.bitrate, - af.file_size + af.file_size, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM release_group_recordings rgr JOIN recordings r ON rgr.recording_id = r.id JOIN audio_files af ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE rgr.release_group_id = ? AND af.library_id = ? @@ -674,6 +720,9 @@ type GetAudioFilesByReleaseGroupByLibraryRow struct { Channels int64 Bitrate int64 FileSize int64 + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) GetAudioFilesByReleaseGroupByLibrary(ctx context.Context, arg GetAudioFilesByReleaseGroupByLibraryParams) ([]GetAudioFilesByReleaseGroupByLibraryRow, error) { @@ -702,6 +751,9 @@ func (q *Queries) GetAudioFilesByReleaseGroupByLibrary(ctx context.Context, arg &i.Channels, &i.Bitrate, &i.FileSize, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ); err != nil { return nil, err } @@ -779,10 +831,15 @@ SELECT COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist, COALESCE(rg.name, '') AS album, - COALESCE(ca.file_path, '') AS cover_art_path + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id @@ -797,6 +854,9 @@ type GetTrackMetadataByPathRow struct { Artist string Album string CoverArtPath string + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) (GetTrackMetadataByPathRow, error) { @@ -809,21 +869,29 @@ func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) ( &i.Artist, &i.Album, &i.CoverArtPath, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ) return i, err } const lookupTrackMetaByPaths = `-- name: LookupTrackMetaByPaths :many -SELECT id, file_path, title, artist_name +SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid FROM track_metadata WHERE file_path IN (/*SLICE:paths*/?) ` type LookupTrackMetaByPathsRow struct { - ID int64 - FilePath string - Title string - ArtistName string + ID int64 + FilePath string + Title string + ArtistName string + Album string + CoverArtPath string + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) LookupTrackMetaByPaths(ctx context.Context, paths []string) ([]LookupTrackMetaByPathsRow, error) { @@ -850,6 +918,11 @@ func (q *Queries) LookupTrackMetaByPaths(ctx context.Context, paths []string) ([ &i.FilePath, &i.Title, &i.ArtistName, + &i.Album, + &i.CoverArtPath, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ); err != nil { return nil, err } diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index 4c1183e..a722561 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -26,6 +26,13 @@ type ArtistCreditArtist struct { CreditID int64 } +type ArtistMetadatum struct { + Mbid string + Source string + Data []byte + FetchedAt time.Time +} + type AudioFile struct { ID int64 FilePath string @@ -50,15 +57,6 @@ type CoverArt struct { MimeType string } -type ExploreCache struct { - UrlKey string - Response string - Mbid sql.NullString - EntityType sql.NullString - ExpiresAt time.Time - CreatedAt time.Time -} - type FileType struct { ID int64 Extension string @@ -69,6 +67,14 @@ type Genre struct { Name string } +type HttpCache struct { + UrlKey string + Response []byte + ExpiresAt time.Time + EntityMbid string + EntityType string +} + type Library struct { ID int64 Name string @@ -139,6 +145,7 @@ type Recording struct { Composer sql.NullString Lyrics sql.NullString Comment sql.NullString + Mbid sql.NullString } type RecordingGenre struct { @@ -194,4 +201,8 @@ type TrackMetadatum struct { LibraryID int64 PlayCount int64 LastPlayed sql.NullTime + CoverArtPath string + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go index a6d94db..28dbe35 100644 --- a/backend/database/sql/sqlcgen/playlists.sql.go +++ b/backend/database/sql/sqlcgen/playlists.sql.go @@ -129,11 +129,16 @@ SELECT COALESCE(ac.text, pt.phantom_artist, '') AS artist, COALESCE(rg.name, pt.phantom_album, '') AS album, COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, - CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom + CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM playlist_tracks pt LEFT JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings @@ -156,6 +161,9 @@ type GetAllPlaylistTracksWithMetadataRow struct { Album string CoverArtPath string IsPhantom int64 + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAllPlaylistTracksWithMetadataRow, error) { @@ -179,6 +187,9 @@ func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAl &i.Album, &i.CoverArtPath, &i.IsPhantom, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ); err != nil { return nil, err } @@ -360,11 +371,16 @@ SELECT COALESCE(ac.text, pt.phantom_artist, '') AS artist, COALESCE(rg.name, pt.phantom_album, '') AS album, COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, - CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom + CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM playlist_tracks pt LEFT JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings @@ -388,6 +404,9 @@ type GetPlaylistTracksWithMetadataRow struct { Album string CoverArtPath string IsPhantom int64 + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID int64) ([]GetPlaylistTracksWithMetadataRow, error) { @@ -411,6 +430,9 @@ func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID &i.Album, &i.CoverArtPath, &i.IsPhantom, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ); err != nil { return nil, err } diff --git a/backend/database/sql/sqlcgen/queue.sql.go b/backend/database/sql/sqlcgen/queue.sql.go index ff4bb0e..8675a61 100644 --- a/backend/database/sql/sqlcgen/queue.sql.go +++ b/backend/database/sql/sqlcgen/queue.sql.go @@ -59,21 +59,40 @@ func (q *Queries) GetQueueTrackCount(ctx context.Context) (int64, error) { const getQueueTracks = `-- name: GetQueueTracks :many SELECT qt.id, qt.audio_file_id, qt.position, af.file_path, COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(a.mbid, '') AS artist_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid FROM queue_tracks qt JOIN audio_files af ON qt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN artists a ON a.id = aca.artist_id +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id ORDER BY qt.position ` type GetQueueTracksRow struct { - ID int64 - AudioFileID int64 - Position int64 - FilePath string - Title string - Artist string + ID int64 + AudioFileID int64 + Position int64 + FilePath string + Title string + Artist string + Album string + CoverArtPath string + ArtistMbid string + ReleaseGroupMbid string + RecordingMbid string } func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) { @@ -92,6 +111,11 @@ func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, erro &i.FilePath, &i.Title, &i.Artist, + &i.Album, + &i.CoverArtPath, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, ); err != nil { return nil, err } diff --git a/backend/database/sql/sqlcgen/recordings.sql.go b/backend/database/sql/sqlcgen/recordings.sql.go index 2d9cedf..5bfda76 100644 --- a/backend/database/sql/sqlcgen/recordings.sql.go +++ b/backend/database/sql/sqlcgen/recordings.sql.go @@ -23,7 +23,7 @@ func (q *Queries) CountRecordingsByArtistCredit(ctx context.Context, artistCredi const createRecording = `-- name: CreateRecording :one INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?) -RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment +RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid ` type CreateRecordingParams struct { @@ -45,6 +45,7 @@ func (q *Queries) CreateRecording(ctx context.Context, arg CreateRecordingParams &i.Composer, &i.Lyrics, &i.Comment, + &i.Mbid, ) return i, err } @@ -54,7 +55,7 @@ INSERT INTO recordings ( name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) -RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment +RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid ` type CreateRecordingFullParams struct { @@ -93,6 +94,7 @@ func (q *Queries) CreateRecordingFull(ctx context.Context, arg CreateRecordingFu &i.Composer, &i.Lyrics, &i.Comment, + &i.Mbid, ) return i, err } @@ -117,7 +119,7 @@ func (q *Queries) DeleteRecording(ctx context.Context, id int64) error { } const getAllRecordings = `-- name: GetAllRecordings :many -SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment FROM recordings +SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid FROM recordings ORDER BY name ` @@ -141,6 +143,7 @@ func (q *Queries) GetAllRecordings(ctx context.Context) ([]Recording, error) { &i.Composer, &i.Lyrics, &i.Comment, + &i.Mbid, ); err != nil { return nil, err } @@ -156,7 +159,7 @@ func (q *Queries) GetAllRecordings(ctx context.Context) ([]Recording, error) { } const getRecording = `-- name: GetRecording :one -SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment FROM recordings +SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid FROM recordings WHERE id = ? LIMIT 1 ` @@ -174,6 +177,7 @@ func (q *Queries) GetRecording(ctx context.Context, id int64) (Recording, error) &i.Composer, &i.Lyrics, &i.Comment, + &i.Mbid, ) return i, err } diff --git a/backend/events/events.go b/backend/events/events.go index 8625a6e..07e004e 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -73,3 +73,8 @@ const ( TrackMetadataChanged = "TrackMetadataChanged" BatchWriteProgress = "BatchWriteProgress" ) + +// Explore / search index events. +const ( + IndexStatusChanged = "IndexStatusChanged" +) diff --git a/backend/explore/artistimage.go b/backend/explore/artistimage.go index e2eb501..2404a6e 100644 --- a/backend/explore/artistimage.go +++ b/backend/explore/artistimage.go @@ -225,6 +225,112 @@ func (p *ArtistImageProvider) GetAliases(artistMBID string) string { return strings.Join(names, " ") } +// ArtistDetails holds the structured metadata extracted from MB's +// artist lookup response. Returned by GetArtistDetails. +type ArtistDetails struct { + Type string + Country string + Disambiguation string + SortName string + Aliases string +} + +// GetArtistDetails returns structured metadata for an artist from +// the cached MB artist-rels response (which we fetch anyway during +// image resolution). Returns nil if not cached. +func (p *ArtistImageProvider) GetArtistDetails(artistMBID string) *ArtistDetails { + cacheKey := "mb:artist-rels:" + artistMBID + + data, ok := p.cache.Get(cacheKey) + if !ok { + return nil + } + + var envelope struct { + Type string `json:"type"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` + SortName string `json:"sort-name"` + Aliases []struct { + Name string `json:"name"` + } `json:"aliases"` + } + + if err := json.Unmarshal(data, &envelope); err != nil { + return nil + } + + names := make([]string, 0, len(envelope.Aliases)) + for _, a := range envelope.Aliases { + if a.Name != "" { + names = append(names, a.Name) + } + } + + return &ArtistDetails{ + Type: envelope.Type, + Country: envelope.Country, + Disambiguation: envelope.Disambiguation, + SortName: envelope.SortName, + Aliases: strings.Join(names, " "), + } +} + +// PreloadArtistRels writes a synthesized mb:artist-rels cache entry +// derived from LB batch metadata. This lets fetchMBRels skip the +// per-artist MB network call — we already have type, country, name, +// and wikidata QID from LB. Aliases and disambiguation are left +// empty (those only come from a real MB call). +// +// The envelope shape matches what fetchMBRels reads, so the cache +// hit is transparent to the image resolution pipeline. +func (p *ArtistImageProvider) PreloadArtistRels(mbid string, meta ArtistMetadata) { + cacheKey := "mb:artist-rels:" + mbid + + // Don't overwrite a real MB response if we already have one. + if data, ok := p.cache.Get(cacheKey); ok && len(data) > 0 { + return + } + + // Construct an envelope compatible with both fetchMBRels + // (which reads `relations`) and GetArtistDetails (which reads + // `type`, `country`, `disambiguation`, `sort-name`, `aliases`). + envelope := struct { + Type string `json:"type"` + Country string `json:"country"` + SortName string `json:"sort-name"` + Disambiguation string `json:"disambiguation"` + Name string `json:"name"` + Relations []mbRelation `json:"relations"` + Aliases []struct { + Name string `json:"name"` + } `json:"aliases"` + }{ + Type: meta.Type, + Country: meta.Country, + Name: meta.Name, + } + + // Add a wikidata relation so getWikidataQID finds the QID. + if meta.WikidataQID != "" { + envelope.Relations = append(envelope.Relations, mbRelation{ + Type: "wikidata", + URL: struct { + Resource string `json:"resource"` + }{ + Resource: "https://www.wikidata.org/wiki/" + meta.WikidataQID, + }, + }) + } + + data, err := json.Marshal(envelope) + if err != nil { + return + } + + p.cache.Set(cacheKey, data, artistImageCacheTTL, mbid, "artist") +} + // --------------------------------------------------------------------------- // Source resolution // --------------------------------------------------------------------------- diff --git a/backend/explore/cache.go b/backend/explore/cache.go index 13ed5d7..5807ba6 100644 --- a/backend/explore/cache.go +++ b/backend/explore/cache.go @@ -3,14 +3,19 @@ package explore import ( "fmt" "log/slog" + "strings" "time" "yellowjacket/backend/database" ) // Cache provides a SQLite-backed response cache with TTL expiry. -// It stores raw JSON API responses keyed by URL and supports -// optional MBID columns for future autotagging lookups. +// Used for short-lived HTTP response caching of search, lookup, +// and popularity API calls. +// +// For long-lived artist metadata (fanart.tv, audiodb, wikidata, +// wikipedia), use ArtistMetadataStore instead — it uses a separate +// table with no TTL and per-source indexing. // // All operations use the shared database.DB connection and its // single-writer constraint (SetMaxOpenConns(1)). @@ -24,16 +29,44 @@ func NewCache(db *database.DB, logger *slog.Logger) *Cache { return &Cache{db: db, logger: logger} } +// artistMetadataSources lists cache key prefixes that should be +// redirected to the artist_metadata store (long-lived, keyed by +// mbid+source). These are enrichment data that changes rarely. +var artistMetadataSources = map[string]bool{ //nolint:gochecknoglobals + "audiodb": true, + "fanart": true, + "wikidata-p18": true, + "wikipedia-lead": true, + "mb:artist-rels": true, +} + +// isArtistMetadataKey returns true if the given cache key should +// route to artist_metadata instead of http_cache. +func isArtistMetadataKey(key string) (string, string, bool) { + for prefix := range artistMetadataSources { + if strings.HasPrefix(key, prefix+":") { + return prefix, strings.TrimPrefix(key, prefix+":"), true + } + } + + return "", "", false +} + // Get returns the cached response for the given URL key if it // exists and has not expired. Returns (data, true) on a cache hit // and (nil, false) on a miss or expired entry. func (c *Cache) Get(key string) ([]byte, bool) { + // Long-lived artist metadata goes to the dedicated table. + if source, mbid, ok := isArtistMetadataKey(key); ok { + return c.getArtistMetadata(source, mbid) + } + rows, err := c.db.QueryContext( - "SELECT response FROM explore_cache WHERE url_key = ? AND expires_at > datetime('now')", + "SELECT response FROM http_cache WHERE url_key = ? AND expires_at > datetime('now')", key, ) if err != nil { - c.logger.Warn("explore cache get error", + c.logger.Warn("http cache get error", "key", key, "err", err, ) @@ -44,15 +77,13 @@ func (c *Cache) Get(key string) ([]byte, bool) { defer func() { _ = rows.Close() }() if !rows.Next() { - c.logger.Debug("explore cache miss", "key", key) - return nil, false } var response string if err := rows.Scan(&response); err != nil { - c.logger.Warn("explore cache scan error", + c.logger.Warn("http cache scan error", "key", key, "err", err, ) @@ -60,14 +91,10 @@ func (c *Cache) Get(key string) ([]byte, bool) { return nil, false } - c.logger.Debug("explore cache hit", "key", key) - return []byte(response), true } -// Set stores a response in the cache with the given TTL. If mbid -// and entityType are non-empty they are stored for future -// autotagging lookups; otherwise they are stored as NULL. +// Set stores a response in the cache with the given TTL. func (c *Cache) Set( key string, data []byte, @@ -75,6 +102,13 @@ func (c *Cache) Set( mbid string, entityType string, ) { + // Long-lived artist metadata goes to the dedicated table (no TTL). + if source, itemMBID, ok := isArtistMetadataKey(key); ok { + c.setArtistMetadata(source, itemMBID, data) + + return + } + seconds := int(ttl.Seconds()) if seconds < 1 { seconds = 1 @@ -83,40 +117,73 @@ func (c *Cache) Set( expr := fmt.Sprintf("datetime('now', '+%d seconds')", seconds) query := fmt.Sprintf( - `INSERT OR REPLACE INTO explore_cache - (url_key, response, mbid, entity_type, expires_at) - VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), %s)`, + `INSERT OR REPLACE INTO http_cache + (url_key, response, entity_mbid, entity_type, expires_at) + VALUES (?, ?, ?, ?, %s)`, expr, ) if _, err := c.db.ExecContext(query, key, string(data), mbid, entityType); err != nil { - c.logger.Warn("explore cache set error", + c.logger.Warn("http cache set error", "key", key, "err", err, ) - } else { - c.logger.Debug("explore cache set", - "key", key, - "ttl", ttl, - "mbid", mbid, - "entityType", entityType, - ) } } -// Evict removes all expired entries from the cache. -func (c *Cache) Evict() { - result, err := c.db.ExecContext( - "DELETE FROM explore_cache WHERE expires_at < datetime('now')", +// getArtistMetadata reads a row from the artist_metadata table. +func (c *Cache) getArtistMetadata(source, mbid string) ([]byte, bool) { + rows, err := c.db.QueryContext( + "SELECT data FROM artist_metadata WHERE source = ? AND mbid = ?", + source, mbid, ) if err != nil { - c.logger.Warn("explore cache evict error", "err", err) + return nil, false + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + return nil, false + } + + var data []byte + if err := rows.Scan(&data); err != nil { + return nil, false + } + + return data, true +} + +// setArtistMetadata writes a row to the artist_metadata table. +func (c *Cache) setArtistMetadata(source, mbid string, data []byte) { + if _, err := c.db.ExecContext( + `INSERT OR REPLACE INTO artist_metadata (source, mbid, data, fetched_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP)`, + source, mbid, data, + ); err != nil { + c.logger.Warn("artist_metadata set error", + "source", source, + "mbid", mbid, + "err", err, + ) + } +} + +// Evict removes all expired entries from the http_cache. Does not +// touch artist_metadata (which has no TTL). +func (c *Cache) Evict() { + result, err := c.db.ExecContext( + "DELETE FROM http_cache WHERE expires_at < datetime('now')", + ) + if err != nil { + c.logger.Warn("http cache evict error", "err", err) return } if n, _ := result.RowsAffected(); n > 0 { - c.logger.Info("explore cache evicted expired entries", + c.logger.Info("http cache evicted expired entries", "count", n, ) } diff --git a/backend/explore/coverartproxy.go b/backend/explore/coverartproxy.go index 9b0c7dc..7b696ca 100644 --- a/backend/explore/coverartproxy.go +++ b/backend/explore/coverartproxy.go @@ -70,26 +70,25 @@ func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy { // GetThumbnail returns a base64-encoded JPEG data URL for the given // release group. Checks local library art first (by name match), -// then disk cache, then fetches from CAA. Returns "" on failure. +// GetThumbnail returns a base64 data URL for an album's cover art. +// Checks local library art first, then disk cache, then fetches from CAA. +// Returns "" on failure. +// +// The mbid argument MUST be a release group MBID. Track-level cover +// art (where you only have a release MBID) should be resolved by +// looking up the parent release group via SearchIndex first. func (p *CoverArtProxy) GetThumbnail( releaseGroupMBID, albumName, artistName string, ) string { - // Source 1: local library cover art (instant). - if albumName != "" { - if dataURL := p.libraryArt(albumName, artistName); dataURL != "" { - return dataURL - } + // Source 1+2: local library art + disk cache (instant). + if cached := p.GetThumbnailCached(releaseGroupMBID, albumName, artistName); cached != "" { + return cached } if p.cacheDir == "" || releaseGroupMBID == "" { return "" } - // Source 2: disk cache from previous CAA fetch (instant). - if cached := p.readCache(releaseGroupMBID); cached != "" { - return cached - } - // Source 3: fetch from Cover Art Archive (slow, cached to disk). url := CoverArtGroupURL(releaseGroupMBID) data, cacheable, err := p.fetch(url) @@ -107,6 +106,136 @@ func (p *CoverArtProxy) GetThumbnail( return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data) } +// GetThumbnailCached checks only local library art and disk cache. +// Returns "" if not cached — does NOT fetch from the network. +func (p *CoverArtProxy) GetThumbnailCached( + releaseGroupMBID, albumName, artistName string, +) string { + // Source 1: local library cover art (instant). + if albumName != "" { + if dataURL := p.libraryArt(albumName, artistName); dataURL != "" { + return dataURL + } + } + + if p.cacheDir == "" || releaseGroupMBID == "" { + return "" + } + + // Source 2: disk cache from previous CAA fetch (instant). + return p.readCache(releaseGroupMBID) +} + +// GetTrackThumbnail returns cover art for a track. Tries, in order: +// 1. Local library art by album/artist name. +// 2. Disk cache for the release group MBID (shared with discography). +// 3. Disk cache for the release MBID (per-track fallback). +// 4. CAA network fetch on the release group (populates RG cache). +// 5. CAA network fetch on the release (populates release cache). +// +// Either or both MBIDs may be empty — whichever is present is tried. +// Release group is preferred because it shares the cache with the +// discography and top-releases sections; release is the fallback for +// tracks whose caa_release_mbid doesn't resolve to a known RG in the +// index (e.g. the track is on a release not fetched for that artist). +func (p *CoverArtProxy) GetTrackThumbnail( + releaseMBID, releaseGroupMBID, albumName, artistName string, +) string { + // Source 1: local library art (instant). + if albumName != "" { + if dataURL := p.libraryArt(albumName, artistName); dataURL != "" { + return dataURL + } + } + + if p.cacheDir == "" { + return "" + } + + // Source 2: disk cache for release group (shared with discography). + if releaseGroupMBID != "" { + if cached := p.readCache(releaseGroupMBID); cached != "" { + return cached + } + } + + // Source 3: disk cache for release (per-track fallback). + if releaseMBID != "" { + if cached := p.readCache(releaseMBID); cached != "" { + return cached + } + } + + // Source 4: CAA network fetch on release group. + if releaseGroupMBID != "" { + url := CoverArtGroupURL(releaseGroupMBID) + data, cacheable, err := p.fetch(url) + + if err == nil && len(data) > 0 { + p.writeCache(releaseGroupMBID, data) + + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data) + } + + if cacheable { + // Mark RG miss so we don't re-fetch it, but fall through + // to the release-level fallback. + p.writeCache(releaseGroupMBID, nil) + } + } + + // Source 5: CAA network fetch on release (fallback). + if releaseMBID != "" { + url := CoverArtURL(releaseMBID) + data, cacheable, err := p.fetch(url) + + if err != nil || len(data) == 0 { + if cacheable { + p.writeCache(releaseMBID, nil) + } + + return "" + } + + p.writeCache(releaseMBID, data) + + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data) + } + + return "" +} + +// GetTrackThumbnailCached returns a cached track thumbnail without +// hitting the network. Tries library art, then RG cache, then +// release cache. Returns "" if nothing is cached. +func (p *CoverArtProxy) GetTrackThumbnailCached( + releaseMBID, releaseGroupMBID, albumName, artistName string, +) string { + if albumName != "" { + if dataURL := p.libraryArt(albumName, artistName); dataURL != "" { + return dataURL + } + } + + if p.cacheDir == "" { + return "" + } + + if releaseGroupMBID != "" { + if cached := p.readCache(releaseGroupMBID); cached != "" { + return cached + } + } + + if releaseMBID != "" { + if cached := p.readCache(releaseMBID); cached != "" { + return cached + } + } + + return "" +} + // --------------------------------------------------------------------------- // Source 1: local library art // --------------------------------------------------------------------------- diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 3c402bc..e205de4 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -2,7 +2,6 @@ package explore import ( "context" - "encoding/json" "log/slog" "math" "sort" @@ -37,14 +36,22 @@ type Service struct { func NewExploreService(logger *slog.Logger, db *database.DB) *Service { cache := NewCache(db, logger.WithGroup("cache")) lbLimiter := NewRateLimiter() - // MB search limiter: burst of 3 (covers one search's 3 concurrent calls) - // then 1/sec refill. The musicbrainzws2 library retries on 429 as backup. - mbSearchLimiter := NewRateLimiterBurst(1, 3) + // Cover Art Archive has its own rate limits, separate from LB. + // Allow 8 concurrent fetches so album art loads quickly. + caaLimiter := NewRateLimiterBurst(8, 8) + // MB search limiter: 3 tokens/sec, burst of 1. This spaces the + // three concurrent search goroutines ~333ms apart instead of + // firing all at once. MusicBrainz uses an all-or-nothing rate + // limit — exceeding 1/sec average causes 503 on ALL requests, + // which triggers the library's retry loop (up to 5 × 1s waits). + // Staggering avoids the 503 entirely while keeping total phase-1 + // latency under 1.5s (333ms stagger + ~1s MB response). + mbSearchLimiter := NewRateLimiterBurst(3, 1) // MB background limiter: strict 1/sec for sustained image resolution calls. mbBackgroundLimiter := NewRateLimiter() mb := NewMusicBrainzClient(cache, mbSearchLimiter, logger.WithGroup("musicbrainz")) lb := NewListenBrainzClient(lbLimiter, cache, logger.WithGroup("listenbrainz")) - artProxy := NewCoverArtProxy(db, lbLimiter) + artProxy := NewCoverArtProxy(db, caaLimiter) artistImg := NewArtistImageProvider( db, cache, mbBackgroundLimiter, logger.WithGroup("artist-image"), ) @@ -72,6 +79,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service { // OnStartup after the Wails runtime is initialised. func (e *Service) SetContext(ctx context.Context) { e.ctx = ctx + e.index.SetContext(ctx) } // StartIndexBuild kicks off the background search index build. @@ -93,6 +101,28 @@ func (e *Service) StopIndexBuild() { e.index.StopBuild() } +// IsIndexReady returns true once the index has been populated. +func (e *Service) IsIndexReady() bool { + return e.index.IsReady() +} + +// WaitForIndexIdle blocks until no index build or artist indexing +// goroutine is running. Does not cancel a running build. +func (e *Service) WaitForIndexIdle() { + e.index.WaitForIdle() +} + +// PopulateLocalCrossReferences updates the local_*_id columns on +// explore_index after a library scan. +func (e *Service) PopulateLocalCrossReferences() { + e.index.PopulateLocalCrossReferences() +} + +// GetIndexStatus returns the current search index build status. +func (e *Service) GetIndexStatus() IndexStatus { + return e.index.GetIndexStatus() +} + // InvalidateIndexDiscographies clears the discography build // timestamp so the next index build re-runs Tiers 2-4. Call // after a library rescan that may have populated new MBIDs. @@ -106,17 +136,20 @@ func (e *Service) InvalidateIndexDiscographies() { // SearchArtists queries MusicBrainz for artists matching the query. func (e *Service) SearchArtists(query string) ([]MBArtist, error) { - return e.mb.SearchArtists(e.ctx, query, mbSearchLimit) + artists, _, err := e.mb.SearchArtists(e.ctx, query, mbSearchLimit) + return artists, err } // SearchReleaseGroups queries MusicBrainz for release groups matching the query. func (e *Service) SearchReleaseGroups(query string) ([]MBReleaseGroup, error) { - return e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit) + rgs, _, err := e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit) + return rgs, err } // SearchRecordings queries MusicBrainz for recordings matching the query. func (e *Service) SearchRecordings(query string) ([]MBRecording, error) { - return e.mb.SearchRecordings(e.ctx, query, mbSearchLimit) + recs, _, err := e.mb.SearchRecordings(e.ctx, query, mbSearchLimit) + return recs, err } // SearchLocal queries only the local FTS5 index and returns results @@ -124,7 +157,7 @@ func (e *Service) SearchRecordings(query string) ([]MBRecording, error) { // ready. The frontend calls this in parallel with Search() to show // instant results while the full pipeline runs. func (e *Service) SearchLocal(query string) *MBSearchResult { - indexHits := e.index.Search(query, 30) //nolint:mnd + indexHits := e.index.Search(query, indexSearchLimit) if len(indexHits) == 0 { return nil } @@ -166,12 +199,64 @@ func (e *Service) SearchLocal(query string) *MBSearchResult { // --------------------------------------------------------------------------- // LookupArtist fetches a single MusicBrainz artist by MBID. +// Checks the local index first — has name, type, country, +// disambiguation, sort_name for indexed artists. Falls back to +// MB API for unknown artists and backfills the index for next time. func (e *Service) LookupArtist(mbid string) (*MBArtist, error) { + if indexed := e.index.LookupArtistByMBID(mbid); indexed != nil && indexed.Title != "" { + artist := &MBArtist{ + MBID: mbid, + Name: indexed.Title, + SortName: indexed.SortName, + Type: indexed.ArtistType, + Country: indexed.Country, + Disambiguation: indexed.Disambiguation, + Popularity: indexed.Popularity, + HasPopularity: indexed.Popularity > 0, + ListenerCount: indexed.ListenerCount, + InLibrary: indexed.InLibrary || indexed.LocalArtistID > 0, + LocalID: indexed.LocalArtistID, + } + + return artist, nil + } + return e.mb.LookupArtist(e.ctx, mbid) } // LookupReleaseGroup fetches a single MusicBrainz release group by MBID. func (e *Service) LookupReleaseGroup(mbid string) (*MBReleaseGroup, error) { + // Try the index first — has title, type, secondary_types, date, artist. + if indexed := e.index.LookupReleaseGroupByMBID(mbid); indexed != nil && indexed.Title != "" { + var secondary []string + if indexed.SecondaryTypes != "" { + secondary = strings.Split(indexed.SecondaryTypes, ",") + } + + rg := &MBReleaseGroup{ + MBID: mbid, + Title: indexed.Title, + ArtistCredit: indexed.ArtistName, + Popularity: indexed.Popularity, + ListenerCount: indexed.ListenerCount, + PrimaryType: indexed.PrimaryType, + SecondaryTypes: secondary, + FirstReleaseDate: indexed.ReleaseDate, + InLibrary: indexed.InLibrary || indexed.LocalReleaseGroupID > 0, + LocalID: indexed.LocalReleaseGroupID, + } + + // Background: fetch full MB data if secondary_types is empty. + // After the first visit this will populate on the next request. + if indexed.SecondaryTypes == "" { + go func() { + _, _ = e.mb.LookupReleaseGroup(e.ctx, mbid) + }() + } + + return rg, nil + } + return e.mb.LookupReleaseGroup(e.ctx, mbid) } @@ -180,31 +265,133 @@ func (e *Service) LookupReleaseGroup(mbid string) (*MBReleaseGroup, error) { // --------------------------------------------------------------------------- // BrowseReleaseGroups fetches release groups for a given artist MBID. +// Checks the local index first for instant results, then fetches from +// MusicBrainz for complete data (secondary types, precise dates). // Also adds results to the search index (Tier 5: organic growth). func (e *Service) BrowseReleaseGroups(artistMBID string) ([]MBReleaseGroup, error) { + // Try the index first — returns instantly if the artist is indexed. + if indexed := e.index.TopReleaseGroupsByArtist(artistMBID, 200); len(indexed) > 0 { + out := make([]MBReleaseGroup, 0, len(indexed)) + + // Check if ANY row has secondary types — if none do, we need + // to refresh from MB to pick them up. This typically happens + // on the first visit after an artist's discography was indexed + // from the LB top-release-groups endpoint (which doesn't + // return secondary types). + hasSecondaryTypes := false + + for _, r := range indexed { + var secondary []string + if r.SecondaryTypes != "" { + secondary = strings.Split(r.SecondaryTypes, ",") + hasSecondaryTypes = true + } + + out = append(out, MBReleaseGroup{ + MBID: r.MBID, + Title: r.Title, + ArtistCredit: r.ArtistName, + Popularity: r.Popularity, + ListenerCount: r.ListenerCount, + PrimaryType: r.PrimaryType, + SecondaryTypes: secondary, + FirstReleaseDate: r.ReleaseDate, + InLibrary: r.InLibrary || r.LocalReleaseGroupID > 0, + LocalID: r.LocalReleaseGroupID, + }) + } + + // Fire MB browse in background if we're missing secondary types + // so the next visit gets them. + if !hasSecondaryTypes { + go func() { + rgs, err := e.mb.BrowseReleaseGroups(e.ctx, artistMBID) + if err == nil && len(rgs) > 0 { + artistName := e.resolveArtistName(artistMBID, rgs) + e.index.AddFromCache(artistName, artistMBID, rgs) + } + }() + } + + return out, nil + } + rgs, err := e.mb.BrowseReleaseGroups(e.ctx, artistMBID) if err != nil { return nil, err } // Tier 5: organic growth — index this discography. - // Look up the artist name from the first result's credit, or - // fall back to the MBID. - artistName := artistMBID - - artist, lookupErr := e.mb.LookupArtist(e.ctx, artistMBID) - if lookupErr == nil && artist != nil { - artistName = artist.Name - } + artistName := e.resolveArtistName(artistMBID, rgs) go e.index.AddFromCache(artistName, artistMBID, rgs) return rgs, nil } +// resolveArtistName picks the best available artist name for a list +// of release groups returned from MB browse-by-artist. MB browse +// doesn't echo back the artist credit on each item (since the artist +// is the query parameter), so we need to find a name from somewhere: +// 1. First non-empty ArtistCredit on any release group +// 2. The local explore_index (if the artist was previously indexed) +// 3. A LookupArtist call to MB (last resort) +// 4. The MBID itself (worst case fallback) +func (e *Service) resolveArtistName(artistMBID string, rgs []MBReleaseGroup) string { + // Try first non-empty ArtistCredit from the release groups. + for _, rg := range rgs { + if rg.ArtistCredit != "" { + return rg.ArtistCredit + } + } + + // Check the index for a previously-indexed artist row. + if indexed := e.index.LookupArtistByMBID(artistMBID); indexed != nil && indexed.Title != "" && indexed.Title != artistMBID { + return indexed.Title + } + + // Last resort: hit MB lookup. + if artist, err := e.mb.LookupArtist(e.ctx, artistMBID); err == nil && artist != nil && artist.Name != "" { + return artist.Name + } + + return artistMBID +} + // BrowseReleases fetches releases for a given release group MBID. func (e *Service) BrowseReleases(releaseGroupMBID string) ([]MBRelease, error) { - return e.mb.BrowseReleases(e.ctx, releaseGroupMBID) + releases, err := e.mb.BrowseReleases(e.ctx, releaseGroupMBID) + if err != nil { + return nil, err + } + + // Collect all recording MBIDs across all releases and check them + // against the local library in a single query. Populates the + // InLibrary flag on each track so the tracklist renderer can + // show the library-status indicator without a per-track roundtrip. + var trackMBIDs []string + for _, rel := range releases { + for _, t := range rel.Tracks { + if t.MBID != "" { + trackMBIDs = append(trackMBIDs, t.MBID) + } + } + } + + if len(trackMBIDs) > 0 { + found := e.libMBID.CheckMBIDs(trackMBIDs) + + for i := range releases { + for j := range releases[i].Tracks { + mbid := releases[i].Tracks[j].MBID + if _, ok := found[mbid]; ok { + releases[i].Tracks[j].InLibrary = true + } + } + } + } + + return releases, nil } // --------------------------------------------------------------------------- @@ -213,40 +400,125 @@ func (e *Service) BrowseReleases(releaseGroupMBID string) ([]MBRelease, error) { // TopRecordingsForArtist returns the most-listened recordings for an artist. func (e *Service) TopRecordingsForArtist(artistMBID string) ([]LBTopRecording, error) { + // Try the local index first (instant, no API call). + if indexed := e.index.TopRecordingsByArtist(artistMBID, 50); len(indexed) > 0 { + out := make([]LBTopRecording, len(indexed)) + for i, r := range indexed { + out[i] = LBTopRecording{ + RecordingMBID: r.MBID, + ArtistName: r.ArtistName, + TrackName: r.Title, + TotalListenCount: r.Popularity, + CAAReleaseMBID: r.CAAReleaseMBID, + ReleaseName: r.ReleaseName, + Length: r.Duration, + InLibrary: r.InLibrary || r.LocalRecordingID > 0, + LocalID: r.LocalRecordingID, + } + } + + return out, nil + } + + // Fall back to LB API. return e.lb.TopRecordingsForArtist(e.ctx, artistMBID) } // TopReleaseGroupsForArtist returns the most-listened release groups for an artist. func (e *Service) TopReleaseGroupsForArtist(artistMBID string) ([]LBTopReleaseGroup, error) { + // Try the local index first (instant, no API call). + if indexed := e.index.TopReleaseGroupsByArtist(artistMBID, 50); len(indexed) > 0 { + out := make([]LBTopReleaseGroup, len(indexed)) + for i, r := range indexed { + out[i] = LBTopReleaseGroup{ + ReleaseGroupMBID: r.MBID, + Title: r.Title, + ArtistName: r.ArtistName, + TotalListenCount: r.Popularity, + Type: r.PrimaryType, + Date: r.ReleaseDate, + CAAReleaseMBID: r.CAAReleaseMBID, + InLibrary: r.InLibrary || r.LocalReleaseGroupID > 0, + LocalID: r.LocalReleaseGroupID, + } + } + + return out, nil + } + + // Fall back to LB API. return e.lb.TopReleaseGroupsForArtist(e.ctx, artistMBID) } // SimilarArtists returns artists similar to the given artist MBID. func (e *Service) SimilarArtists(artistMBID string) ([]LBSimilarArtist, error) { + // Try the pre-computed similar_artist_map first (instant, no API call). + // This is populated during Tier 4 for library artists and their network. + rows, err := e.db.QueryContext(` + SELECT similar_artist_mbid, similar_artist_name, score + FROM similar_artist_map + WHERE source_artist_mbid = ? + ORDER BY score DESC + `, artistMBID) + if err == nil { + defer func() { _ = rows.Close() }() + + var results []LBSimilarArtist + + for rows.Next() { + var a LBSimilarArtist + if err := rows.Scan(&a.ArtistMBID, &a.Name, &a.Score); err == nil { + results = append(results, a) + } + } + + if len(results) > 0 { + return results, nil + } + } + + // Fall back to LB labs API. return e.lb.SimilarArtists(e.ctx, artistMBID) } // GetArtistPlayCount returns the total LB listen count for an artist. // Returns 0 if unknown. func (e *Service) GetArtistPlayCount(artistMBID string) int { + // Try the local index first (instant). + if pop := e.index.GetPopularity(artistMBID); pop > 0 { + return pop + } + + // Fall back to LB API. pop, err := e.lb.ArtistPopularity(e.ctx, []string{artistMBID}) if err != nil || len(pop) == 0 { return 0 } - return pop[artistMBID] + // Backfill index for next time. + go e.index.BackfillPopularity(pop) + + return pop[artistMBID].ListenCount } // GetLibrarySimilarArtists returns similar artists to the given // MBID that are also in the user's local library. Uses the // pre-computed similar_artist_map table (populated during Tier 4 // index build) joined with the artists table. No API calls. +// +// The artists table allows multiple rows with the same MBID +// (different artist credits like "A feat. B" that resolve to the +// same MB artist), so we use EXISTS instead of JOIN to avoid +// duplicating similar_artist_map rows. func (e *Service) GetLibrarySimilarArtists(artistMBID string) []LBSimilarArtist { rows, err := e.db.QueryContext(` SELECT s.similar_artist_mbid, s.similar_artist_name, s.score FROM similar_artist_map s - JOIN artists a ON a.mbid = s.similar_artist_mbid WHERE s.source_artist_mbid = ? + AND EXISTS ( + SELECT 1 FROM artists a + WHERE a.mbid = s.similar_artist_mbid + ) ORDER BY s.score DESC `, artistMBID) if err != nil { @@ -294,6 +566,54 @@ func (e *Service) GetThumbnail(releaseGroupMBID, albumName, artistName string) s return e.artProxy.GetThumbnail(releaseGroupMBID, albumName, artistName) } +// GetTrackThumbnail returns cover art for a track. Accepts both +// the track's CAA release MBID and the resolved parent release +// group MBID (either may be empty). Tries the RG first to reuse +// discography cache; falls back to the release-level CAA endpoint +// when the RG isn't known — useful when the track's preferred CAA +// release doesn't belong to any RG currently in the index. +func (e *Service) GetTrackThumbnail(releaseMBID, releaseGroupMBID, albumName, artistName string) string { + return e.artProxy.GetTrackThumbnail(releaseMBID, releaseGroupMBID, albumName, artistName) +} + +// TrackThumbnailRequest is a single item in a batch track thumbnail +// request. Either ReleaseMBID or ReleaseGroupMBID may be empty; +// the proxy tries whichever is present. +type TrackThumbnailRequest struct { + Key string `json:"key"` // stable key used in the returned map + ReleaseMBID string `json:"releaseMbid"` + ReleaseGroupMBID string `json:"releaseGroupMbid"` + AlbumName string `json:"albumName"` + ArtistName string `json:"artistName"` +} + +// GetTrackThumbnails returns ONLY cached/local art for track +// requests, keyed by the caller-provided Key so callers can map +// results back to rows in their UI. +func (e *Service) GetTrackThumbnails(requests []TrackThumbnailRequest) map[string]string { + result := make(map[string]string, len(requests)) + + for _, req := range requests { + dataURL := e.artProxy.GetTrackThumbnailCached( + req.ReleaseMBID, req.ReleaseGroupMBID, req.AlbumName, req.ArtistName, + ) + if dataURL != "" { + result[req.Key] = dataURL + } + } + + return result +} + +// ResolveReleaseGroupMBIDs takes a list of CAA release MBIDs (from +// recording metadata) and returns a map of release MBID → release +// group MBID. The frontend uses this to fetch track cover art via +// the parent release group, reusing whatever cache exists for the +// album already. +func (e *Service) ResolveReleaseGroupMBIDs(caaReleaseMBIDs []string) map[string]string { + return e.index.ReleaseGroupMBIDsForCAAReleaseMBIDs(caaReleaseMBIDs) +} + // ThumbnailRequest is a single item in a batch thumbnail request. type ThumbnailRequest struct { MBID string `json:"mbid"` @@ -303,11 +623,15 @@ type ThumbnailRequest struct { // GetThumbnails fetches multiple thumbnails in one call and returns // a map of MBID → base64 data URL. Entries with no art are omitted. +// GetThumbnails returns ONLY cached/local art instantly — no network +// fetches. For items missing from the cache, the frontend should +// call GetThumbnail() individually so results stream in rather than +// blocking on a batch. func (e *Service) GetThumbnails(requests []ThumbnailRequest) map[string]string { result := make(map[string]string, len(requests)) for _, req := range requests { - dataURL := e.artProxy.GetThumbnail(req.MBID, req.AlbumName, req.ArtistName) + dataURL := e.artProxy.GetThumbnailCached(req.MBID, req.AlbumName, req.ArtistName) if dataURL != "" { result[req.MBID] = dataURL } @@ -324,6 +648,24 @@ func (e *Service) GetArtistImageURL(artistMBID string) string { return e.artistImg.GetArtistImage(artistMBID) } +// GetArtistImageCached returns a base64 data URL for the artist's +// photo ONLY if it's already on disk — no MB/Wikidata resolution +// or Wikimedia fetch. Safe to call from library-only mode. +// Returns "" if not cached. +func (e *Service) GetArtistImageCached(artistMBID string) string { + return e.artistImg.GetCachedImage(artistMBID) +} + +// GetArtistImageCachedPath returns the asset-handler URL path for +// the artist's cached medium thumbnail, e.g. +// "/artist-images/b1/b10bbbfc-.../primary_md.jpg". No base64, no +// network calls — just a disk existence check. Returns "" if no +// image is cached. +func (e *Service) GetArtistImageCachedPath(artistMBID string) string { + _, medium, _, _ := e.artistImg.GetImageURLs(artistMBID) + return medium +} + // CheckLibraryMBIDs returns which of the given MBIDs exist in the // local music library. Returns a map of MBID → entity type // ("artist", "release_group", "recording"). @@ -331,6 +673,49 @@ func (e *Service) CheckLibraryMBIDs(mbids []string) map[string]string { return e.libMBID.CheckMBIDs(mbids) } +// PersonalizationResult holds popularity and personalization signals +// for a single MBID. Exported for Wails binding. +type PersonalizationResult struct { + Popularity int `json:"popularity"` + ListenerCount int `json:"listenerCount"` + InLibrary bool `json:"inLibrary"` + SimilarityScore int `json:"similarityScore"` +} + +// GetPopularityBatch returns LB popularity and personalization +// signals for a batch of MBIDs from the local search index. +func (e *Service) GetPopularityBatch(mbids []string) map[string]PersonalizationResult { + batch := e.index.GetPopularityBatch(mbids) + if batch == nil { + return make(map[string]PersonalizationResult) + } + + out := make(map[string]PersonalizationResult, len(batch.Popularity)) + for mbid, pop := range batch.Popularity { + out[mbid] = PersonalizationResult{ + Popularity: pop, + ListenerCount: batch.ListenerCount[mbid], + InLibrary: batch.InLibrary[mbid], + SimilarityScore: batch.SimilarityScores[mbid], + } + } + + // Include entries that have library/similar flags but no popularity. + for mbid := range batch.InLibrary { + if _, ok := out[mbid]; !ok { + out[mbid] = PersonalizationResult{InLibrary: true, SimilarityScore: batch.SimilarityScores[mbid]} + } + } + + for mbid, score := range batch.SimilarityScores { + if _, ok := out[mbid]; !ok { + out[mbid] = PersonalizationResult{SimilarityScore: score} + } + } + + return out +} + // GetArtistMBID returns the MusicBrainz ID for a local library // artist by name, or "" if not found or no MBID tagged. func (e *Service) GetArtistMBID(artistName string) string { @@ -376,12 +761,21 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { // Build the Lucene query: AND terms with wildcard on last. luceneQuery := buildLuceneQuery(query) + // For RGs, also search by artist credit so that "queen" returns + // albums BY Queen, not just titles containing "queen". + rgQuery := buildLuceneQueryWithArtist(query, "releasegroup", "artist") + // Recordings search by title only — the OR with artist caused + // double-match inflation where tracks by "Queen" with "queen" in + // the title got artificially boosted over more popular results. + // The local index handles artist→recording discovery via + // popularity-weighted FTS across title + artist_name + aliases. + recQuery := buildLuceneQuery(query) e.logger.Info("search started", "query", query, "lucene", luceneQuery) // Phase 0: query local popularity index (instant, no API calls). p0Start := time.Now() - indexHits := e.index.Search(query, 30) //nolint:mnd + indexHits := e.index.Search(query, indexSearchLimit) //nolint:mnd p0Dur := time.Since(p0Start) e.logger.Info("search phase 0 complete (index)", @@ -392,6 +786,10 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { // Phase 1: concurrent MB search (3 goroutines) with a deadline // so a slow MusicBrainz server doesn't hold up the whole search. + // + // First pass uses a small limit to discover total match counts. + // If MB reports many matches, a second pass re-fetches with a + // larger limit so the ranking pipeline has better material. p1Start := time.Now() mbCtx, mbCancel := context.WithTimeout(e.ctx, searchMBTimeout) @@ -403,6 +801,17 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { wg sync.WaitGroup ) + type mbInitial struct { + artists []MBArtist + rgs []MBReleaseGroup + recordings []MBRecording + artistN int + rgN int + recN int + } + + var initial mbInitial + type searchFunc struct { name string fn func() @@ -413,11 +822,13 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { name: "artists", fn: func() { t := time.Now() - artists, err := e.mb.SearchArtists(mbCtx, luceneQuery, mbSearchLimit) + artists, total, err := e.mb.SearchArtists(mbCtx, luceneQuery, mbSearchLimit) e.logger.Info("search MB sub-call", "entity", "artists", "elapsed", time.Since(t).Round(time.Millisecond), + "results", len(artists), + "totalMatches", total, "cached", err == nil && time.Since(t) < 5*time.Millisecond, ) @@ -432,7 +843,8 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { } mu.Lock() - result.Artists = artists + initial.artists = artists + initial.artistN = total mu.Unlock() }, }, @@ -440,11 +852,13 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { name: "releaseGroups", fn: func() { t := time.Now() - rgs, err := e.mb.SearchReleaseGroups(mbCtx, luceneQuery, mbSearchLimit) + rgs, total, err := e.mb.SearchReleaseGroups(mbCtx, rgQuery, mbSearchLimit) e.logger.Info("search MB sub-call", "entity", "releaseGroups", "elapsed", time.Since(t).Round(time.Millisecond), + "results", len(rgs), + "totalMatches", total, "cached", err == nil && time.Since(t) < 5*time.Millisecond, ) @@ -459,7 +873,8 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { } mu.Lock() - result.ReleaseGroups = rgs + initial.rgs = rgs + initial.rgN = total mu.Unlock() }, }, @@ -467,11 +882,13 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { name: "recordings", fn: func() { t := time.Now() - recs, err := e.mb.SearchRecordings(mbCtx, luceneQuery, mbSearchLimit) + recs, total, err := e.mb.SearchRecordings(mbCtx, recQuery, mbSearchLimit) e.logger.Info("search MB sub-call", "entity", "recordings", "elapsed", time.Since(t).Round(time.Millisecond), + "results", len(recs), + "totalMatches", total, "cached", err == nil && time.Since(t) < 5*time.Millisecond, ) @@ -486,7 +903,8 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { } mu.Lock() - result.Recordings = recs + initial.recordings = recs + initial.recN = total mu.Unlock() }, }, @@ -504,6 +922,10 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { wg.Wait() + result.Artists = initial.artists + result.ReleaseGroups = initial.rgs + result.Recordings = initial.recordings + p1Dur := time.Since(p1Start) e.logger.Info("search phase 1 complete (MB)", @@ -511,6 +933,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { "artists", len(result.Artists), "releaseGroups", len(result.ReleaseGroups), "recordings", len(result.Recordings), + "expanded", len(result.Artists) > mbSearchLimit || len(result.ReleaseGroups) > mbSearchLimit || len(result.Recordings) > mbSearchLimit, "elapsed", p1Dur.Round(time.Millisecond), ) @@ -520,10 +943,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { p2Start := time.Now() indexReady := e.index.IsReady() - // Phase 2a: always fetch LB artist popularity (single POST, - // ~200ms). This ensures correct ranking regardless of index - // coverage. The index fast path is still used for release - // groups and recordings where LB popularity is less critical. + // Phase 2a: resolve artist popularity and library membership. artistMBIDs := make([]string, 0, len(result.Artists)) for _, a := range result.Artists { if a.MBID != "" { @@ -531,72 +951,113 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { } } - artistPop, _ := e.lb.ArtistPopularity(e.ctx, artistMBIDs) - if artistPop == nil { - artistPop = make(map[string]int) - } + artistPop := make(map[string]int) + libMBIDs := make(map[string]bool) + simScores := make(map[string]int) - // Merge index popularity for artists the index knows about - // (may have higher counts from aggregation). if indexReady { + // Fast path: use local index data only — no API call. + // The batch includes popularity, in_library, and similarity scores. batch := e.index.GetPopularityBatch(artistMBIDs) if batch != nil { for mbid, pop := range batch.Popularity { - if pop > artistPop[mbid] { - artistPop[mbid] = pop - } + artistPop[mbid] = pop + } + + libMBIDs = batch.InLibrary + simScores = batch.SimilarityScores + } + + // Fill in missing artist popularity from LB synchronously + // (with a tight timeout). Without this, artists not yet + // indexed get popularity 0 and the rerank can't + // differentiate them from each other, producing nonsense + // ordering for result sets where MB gave every candidate + // the same text relevance score. + var missingPop []string + for _, mbid := range artistMBIDs { + if artistPop[mbid] <= 0 { + missingPop = append(missingPop, mbid) } } - } - // Build library set and rerank artists. - libCheck := e.libMBID.CheckMBIDs(artistMBIDs) - libMBIDs := make(map[string]bool) - for mbid, entityType := range libCheck { - if entityType == "artist" { - libMBIDs[mbid] = true + if len(missingPop) > 0 { + popCtx, popCancel := context.WithTimeout(e.ctx, searchSlowPathTimeout) + + pop, err := e.lb.ArtistPopularity(popCtx, missingPop) + popCancel() + + if err == nil && pop != nil { + for mbid, data := range pop { + if data.ListenCount > 0 { + artistPop[mbid] = data.ListenCount + } + } + + go e.index.BackfillPopularity(pop) + } + } + } else { + // Slow path: fetch from LB API with a tight timeout + // so a hung LB server doesn't stall the search. + popCtx, popCancel := context.WithTimeout(e.ctx, 2*time.Second) + pop, _ := e.lb.ArtistPopularity(popCtx, artistMBIDs) + popCancel() + + if pop != nil { + artistPop = listenCounts(pop) + + go e.index.BackfillPopularity(pop) + } + + // Still need library/similar membership from the index. + batch := e.index.GetPopularityBatch(artistMBIDs) + if batch != nil { + libMBIDs = batch.InLibrary + simScores = batch.SimilarityScores } } - // Mark popularity on artists for downstream use. + // Mark popularity and library status on artists for downstream use. for i := range result.Artists { if pop, ok := artistPop[result.Artists[i].MBID]; ok && pop > 0 { result.Artists[i].HasPopularity = true result.Artists[i].Popularity = pop } + + if libMBIDs[result.Artists[i].MBID] { + result.Artists[i].InLibrary = true + } } - rerankArtists(result.Artists, artistPop, libMBIDs) + rerankArtistsPersonalized(result.Artists, artistPop, libMBIDs, simScores) // Phase 2b: rerank release groups and recordings. if indexReady { - // Use index for RGs and recordings (good coverage, no API call). e.boostWithIndexPopularityRGsAndRecs(&result) } else { - // Phase 2: LB popularity lookups (3 POST calls, rate-limited). - // Use a tight deadline so a slow LB/MB doesn't stall the search. + // Slow path: LB popularity + cross-reference in parallel. slowCtx, slowCancel := context.WithTimeout(e.ctx, searchSlowPathTimeout) - lbStart := time.Now() - e.boostWithPopularity(&result) - lbDur := time.Since(lbStart) + var wgSlow sync.WaitGroup + wgSlow.Add(2) //nolint:mnd - // Phase 3: cross-reference artist discographies. - // Skip if the slow-path budget is already exhausted. - xrefStart := time.Now() + // Leg 1: LB popularity for RGs and recordings. + go func() { + defer wgSlow.Done() + e.boostWithPopularityRGsAndRecs(&result) + }() - if slowCtx.Err() == nil { - e.crossReferenceAlbums(slowCtx, query, &result) - } + // Leg 2: cross-reference artist discographies. + go func() { + defer wgSlow.Done() + if slowCtx.Err() == nil { + e.crossReferenceAlbums(slowCtx, query, &result) + } + }() - xrefDur := time.Since(xrefStart) + wgSlow.Wait() slowCancel() - - e.logger.Info("search slow path breakdown", - "query", query, - "lbPopularity", lbDur.Round(time.Millisecond), - "crossRef", xrefDur.Round(time.Millisecond), - ) } p2Dur := time.Since(p2Start) @@ -618,6 +1079,9 @@ func (e *Service) Search(query string) (*MBSearchResult, error) { // Phase 6: filter low-scoring results and cap counts. filterAndCap(&result) + // Phase 7: resolve top result cards via intent scoring. + result.TopResults = e.resolveTopResults(query, &result) + totalDur := time.Since(searchStart) e.logger.Info("search completed", @@ -837,19 +1301,35 @@ func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) { rgMBIDs[rg.MBID] = true } - // Collect new entries from index. - var newArtists []MBArtist + recMBIDs := make(map[string]bool, len(result.Recordings)) + for _, r := range result.Recordings { + recMBIDs[r.MBID] = true + } + // Collect new entries from index that MB didn't return. + var newArtists []MBArtist var newRGs []MBReleaseGroup + var newRecs []MBRecording for _, h := range hits { switch h.EntityType { case "artist": if !artistMBIDs[h.MBID] { + score := int(float64(scalePopularity(h.Popularity)) * 0.5) + newArtists = append(newArtists, MBArtist{ - MBID: h.MBID, - Name: h.Title, - Score: scalePopularity(h.Popularity), + MBID: h.MBID, + Name: h.Title, + Type: h.ArtistType, + Country: h.Country, + Disambiguation: h.Disambiguation, + SortName: h.SortName, + Score: score, + HasPopularity: h.Popularity > 0, + Popularity: h.Popularity, + ListenerCount: h.ListenerCount, + InLibrary: h.InLibrary || h.LocalArtistID > 0, + LocalID: h.LocalArtistID, }) artistMBIDs[h.MBID] = true @@ -857,35 +1337,53 @@ func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) { case "release_group": if !rgMBIDs[h.MBID] { - rg := MBReleaseGroup{ - MBID: h.MBID, - Title: h.Title, - ArtistCredit: h.ArtistName, + score := int(float64(scalePopularity(h.Popularity)) * 0.5) + + var secondary []string + if h.SecondaryTypes != "" { + secondary = strings.Split(h.SecondaryTypes, ",") } - // Extract type from extra_json if available. - if h.ExtraJSON != "" { - var extra map[string]string - if err := json.Unmarshal([]byte(h.ExtraJSON), &extra); err == nil { - rg.PrimaryType = extra["type"] - } - } - - newRGs = append(newRGs, rg) - + newRGs = append(newRGs, MBReleaseGroup{ + MBID: h.MBID, + Title: h.Title, + ArtistCredit: h.ArtistName, + Score: score, + Popularity: h.Popularity, + ListenerCount: h.ListenerCount, + PrimaryType: h.PrimaryType, + SecondaryTypes: secondary, + FirstReleaseDate: h.ReleaseDate, + InLibrary: h.InLibrary || h.LocalReleaseGroupID > 0, + LocalID: h.LocalReleaseGroupID, + }) rgMBIDs[h.MBID] = true } case "recording": - // Skip index recordings — they lack duration data and - // don't add value over MB search results which have it. - // Index artists and release groups are still merged - // because they carry popularity data the MB results lack. - continue + if !recMBIDs[h.MBID] { + score := int(float64(scalePopularity(h.Popularity)) * 0.5) + + newRecs = append(newRecs, MBRecording{ + MBID: h.MBID, + Title: h.Title, + Length: h.Duration, + ArtistCredit: h.ArtistName, + Score: score, + Popularity: h.Popularity, + ListenerCount: h.ListenerCount, + InLibrary: h.InLibrary || h.LocalRecordingID > 0, + LocalID: h.LocalRecordingID, + }) + + recMBIDs[h.MBID] = true + } } } - // Prepend index hits so they appear first. + // Prepend index hits so they appear before MB-only results. + // The subsequent reranking and filtering passes will sort + // everything by blended score. if len(newArtists) > 0 { result.Artists = append(newArtists, result.Artists...) } @@ -893,6 +1391,10 @@ func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) { if len(newRGs) > 0 { result.ReleaseGroups = append(newRGs, result.ReleaseGroups...) } + + if len(newRecs) > 0 { + result.Recordings = append(newRecs, result.Recordings...) + } } // scalePopularity maps a raw LB listen count to a 0–100 score @@ -918,10 +1420,21 @@ func scalePopularity(listens int) int { // --------------------------------------------------------------------------- func filterAndCap(result *MBSearchResult) { - // Filter artists: remove SPAs and low-scoring results. + // Filter artists: remove SPAs, low-scoring results, and + // low-popularity garbage when better alternatives exist. if len(result.Artists) > 0 { filtered := result.Artists[:0] + // Find the max popularity among artists to calibrate the + // garbage threshold. If ANY artist has real popularity, + // suppress zero-popularity results. + maxPop := 0 + for _, a := range result.Artists { + if a.Popularity > maxPop { + maxPop = a.Popularity + } + } + for _, a := range result.Artists { if mbSpecialPurposeArtists[a.MBID] { continue @@ -931,12 +1444,31 @@ func filterAndCap(result *MBSearchResult) { continue } + // Drop very-low-popularity results when the result + // set contains meaningfully popular alternatives. + if maxPop >= minPopularityFloor && a.HasPopularity && a.Popularity < minPopularityFloor { + continue + } + filtered = append(filtered, a) } result.Artists = filtered } + // Filter release groups by minimum blended score + popularity floor. + if len(result.ReleaseGroups) > 0 { + filtered := result.ReleaseGroups[:0] + + for _, r := range result.ReleaseGroups { + if r.Score >= minBlendedScore { + filtered = append(filtered, r) + } + } + + result.ReleaseGroups = filtered + } + // Filter recordings by minimum blended score. if len(result.Recordings) > 0 { filtered := result.Recordings[:0] @@ -969,19 +1501,23 @@ func filterAndCap(result *MBSearchResult) { // --------------------------------------------------------------------------- const ( - // Blending weights for final score. - relevanceWeight = 0.4 - popularityWeight = 0.6 + // mbSearchLimit is the initial limit passed to each MB search call. + // The pipeline may re-fetch with a larger limit (up to mbSearchMaxLimit) + // when MB reports many total matches. + mbSearchLimit = 25 - // mbSearchLimit is passed to each MB search call. Larger than - // maxResults to give the ranking pipeline more raw material. - // Noise is filtered out by name-match tiers and score cutoffs. - mbSearchLimit = 50 + // mbSearchMaxLimit caps the expanded fetch. MB's API maximum is 100. + mbSearchMaxLimit = 75 + + // indexSearchLimit is the number of results to fetch from the local + // popularity index (Phase 0). Larger than maxResults because + // results are filtered and the index is the primary search domain. + indexSearchLimit = 60 // searchMBTimeout is the maximum time to wait for MusicBrainz // API responses during interactive search. If MB is slow, // results degrade to index-only rather than blocking the user. - searchMBTimeout = 4 * time.Second + searchMBTimeout = 3 * time.Second // searchSlowPathTimeout caps the total time spent on the slow // path (LB popularity + cross-referencing). When the index @@ -998,34 +1534,45 @@ const ( // below this regardless of popularity. minBlendedScore = 15 - // libraryScoreBonus is added to library artists' blended scores - // after normalization. Applied post-blending so it doesn't - // pollute the maxPop denominator. - libraryScoreBonus = 25 + // minPopularityFloor is the minimum popularity required when + // higher-popularity alternatives exist. Results below this + // threshold are dropped unless every result in that entity type + // is below it (to avoid empty results for niche queries). + minPopularityFloor = 50 + + relevanceWeight = 0.35 + popularityWeight = 0.50 + personalizationWeight = 0.15 + + // Personalization signal values (0.0–1.0). + personalInLibrary = 1.0 + personalSimilar = 0.5 ) // tierBonus maps artist name-match tiers to percentage score multipliers. -// Applied as: score = score * (1 + multiplier). A popular lower-tier -// result can overcome the tier advantage when the popularity gap is -// proportionally larger than the tier difference. +// Applied as: score = score * (1 + multiplier). The spread is aggressive: +// close matches get amplified so popularity can dominate among them, +// while distant matches get heavily penalized to suppress garbage. // //nolint:gochecknoglobals var tierBonus = map[int]float64{ - 0: 0.15, // exact match: +15% - 1: 0.12, // starts with: +12% - 2: -0.05, // substring (query buried in name): -5% - 3: -0.15, // no substring match: -15% + 0: 0.25, // exact match: +25% + 1: 0.15, // starts with: +15% + 2: -0.10, // substring (query buried in name): -10% + 3: -0.30, // no substring match: -30% } // rgTierBonus maps release group match tiers to percentage multipliers. +// More aggressive spread to suppress results that match neither title +// nor artist credit. // //nolint:gochecknoglobals var rgTierBonus = map[int]float64{ - 0: 0.15, // artist credit exact match: +15% - 1: 0.10, // artist credit contains query: +10% - 2: 0.05, // title exact match: +5% - 3: 0.0, // title contains query: no change - 4: -0.10, // no match: -10% + 0: 0.20, // artist credit exact match: +20% + 1: 0.12, // artist credit contains query: +12% + 2: 0.05, // title exact match: +5% + 3: 0.0, // title contains query: no change + 4: -0.25, // no match: -25% } // mbSpecialPurposeArtists is a set of MusicBrainz Special Purpose @@ -1090,27 +1637,41 @@ func (e *Service) boostWithIndexPopularity(result *MBSearchResult) { result.Artists[i].HasPopularity = true result.Artists[i].Popularity = pop } + + if batch.InLibrary[a.MBID] { + result.Artists[i].InLibrary = true + } } - rerankArtists(result.Artists, artistPop, batch.InLibrary) + rerankArtistsPersonalized(result.Artists, artistPop, batch.InLibrary, batch.SimilarityScores) rgPop := make(map[string]int, len(result.ReleaseGroups)) - for _, rg := range result.ReleaseGroups { + for i, rg := range result.ReleaseGroups { if pop, ok := batch.Popularity[rg.MBID]; ok { rgPop[rg.MBID] = pop + result.ReleaseGroups[i].Popularity = pop + } + + if batch.InLibrary[rg.MBID] { + result.ReleaseGroups[i].InLibrary = true } } - rerankReleaseGroups(result.ReleaseGroups, rgPop) + rerankReleaseGroupsPersonalized(result.ReleaseGroups, rgPop, batch.InLibrary, batch.SimilarityScores) recPop := make(map[string]int, len(result.Recordings)) - for _, r := range result.Recordings { + for i, r := range result.Recordings { if pop, ok := batch.Popularity[r.MBID]; ok { recPop[r.MBID] = pop + result.Recordings[i].Popularity = pop + } + + if batch.InLibrary[r.MBID] { + result.Recordings[i].InLibrary = true } } - rerankRecordings(result.Recordings, recPop) + rerankRecordingsPersonalized(result.Recordings, recPop, batch.InLibrary, batch.SimilarityScores) } // boostWithIndexPopularityRGsAndRecs reranks release groups and @@ -1138,40 +1699,161 @@ func (e *Service) boostWithIndexPopularityRGsAndRecs(result *MBSearchResult) { batch := e.index.GetPopularityBatch(allMBIDs) if batch == nil { - return + batch = &PopularityBatchResult{ + Popularity: map[string]int{}, + InLibrary: map[string]bool{}, + } + } + + // Collect MBIDs the index had no popularity for. For result sets + // where every candidate has identical MB relevance (e.g. many + // covers of the same song), missing popularity means the rerank + // has no signal to pick between them — so fall back to the LB + // popularity API for just the missing entries. This keeps the + // common path cache-only while correctness-critical cases get + // a ~1 round-trip to LB. + missingRecs := make([]string, 0) + for _, r := range result.Recordings { + if r.MBID == "" { + continue + } + if _, ok := batch.Popularity[r.MBID]; !ok { + missingRecs = append(missingRecs, r.MBID) + } + } + + missingRGs := make([]string, 0) + for _, rg := range result.ReleaseGroups { + if rg.MBID == "" { + continue + } + if _, ok := batch.Popularity[rg.MBID]; !ok { + missingRGs = append(missingRGs, rg.MBID) + } + } + + if len(missingRecs) > 0 || len(missingRGs) > 0 { + e.fillMissingPopularity(batch, missingRecs, missingRGs) } rgPop := make(map[string]int, len(result.ReleaseGroups)) - for _, rg := range result.ReleaseGroups { + for i, rg := range result.ReleaseGroups { if pop, ok := batch.Popularity[rg.MBID]; ok { rgPop[rg.MBID] = pop + result.ReleaseGroups[i].Popularity = pop + } + + if batch.InLibrary[rg.MBID] { + result.ReleaseGroups[i].InLibrary = true } } rerankReleaseGroups(result.ReleaseGroups, rgPop) recPop := make(map[string]int, len(result.Recordings)) - for _, r := range result.Recordings { + for i, r := range result.Recordings { if pop, ok := batch.Popularity[r.MBID]; ok { recPop[r.MBID] = pop + result.Recordings[i].Popularity = pop + } + + if batch.InLibrary[r.MBID] { + result.Recordings[i].InLibrary = true } } rerankRecordings(result.Recordings, recPop) } -// boostWithPopularity fetches ListenBrainz listen counts for all -// entities in result and re-sorts each slice using a blended score -// of MB text relevance + log-scaled popularity. Modifies result -// in place. Failures are logged and degrade to MB-only ordering. +// fillMissingPopularity fetches LB popularity for recordings and +// release groups that weren't in the local index, merging the +// results back into batch.Popularity. Also backfills the index in +// the background so subsequent searches hit the cache. Runs the +// two LB POST calls concurrently and bounds the total wait to +// searchSlowPathTimeout so a slow LB response can't block search. +func (e *Service) fillMissingPopularity( + batch *PopularityBatchResult, + missingRecs []string, + missingRGs []string, +) { + ctx, cancel := context.WithTimeout(e.ctx, searchSlowPathTimeout) + defer cancel() -func (e *Service) boostWithPopularity(result *MBSearchResult) { - // Collect MBIDs per entity type. - artistMBIDs := make([]string, len(result.Artists)) - for i, a := range result.Artists { - artistMBIDs[i] = a.MBID + var ( + recPop map[string]PopularityData + rgPop map[string]PopularityData + wg sync.WaitGroup + ) + + if len(missingRecs) > 0 { + wg.Add(1) + + go func() { + defer wg.Done() + + pop, err := e.lb.RecordingPopularity(ctx, missingRecs) + if err != nil { + e.logger.Debug("search: fill missing recording popularity failed", + "count", len(missingRecs), "error", err) + + return + } + + recPop = pop + }() } + if len(missingRGs) > 0 { + wg.Add(1) + + go func() { + defer wg.Done() + + pop, err := e.lb.ReleaseGroupPopularity(ctx, missingRGs) + if err != nil { + e.logger.Debug("search: fill missing RG popularity failed", + "count", len(missingRGs), "error", err) + + return + } + + rgPop = pop + }() + } + + wg.Wait() + + // Merge LB results into the batch map so the subsequent rerank + // picks them up without needing a second lookup path. + for mbid, data := range recPop { + batch.Popularity[mbid] = data.ListenCount + if batch.ListenerCount != nil { + batch.ListenerCount[mbid] = data.ListenerCount + } + } + + for mbid, data := range rgPop { + batch.Popularity[mbid] = data.ListenCount + if batch.ListenerCount != nil { + batch.ListenerCount[mbid] = data.ListenerCount + } + } + + // Backfill the index in the background so next time this query + // runs, the index has the answer and we skip the LB round-trip. + if len(recPop) > 0 { + go e.index.BackfillPopularity(recPop) + } + + if len(rgPop) > 0 { + go e.index.BackfillPopularity(rgPop) + } +} + +// boostWithPopularityRGsAndRecs fetches LB popularity for release +// groups and recordings only (artist popularity is fetched separately +// in the main search path). Runs two concurrent POST calls. +func (e *Service) boostWithPopularityRGsAndRecs(result *MBSearchResult) { recordingMBIDs := make([]string, len(result.Recordings)) for i, r := range result.Recordings { recordingMBIDs[i] = r.MBID @@ -1182,28 +1864,14 @@ func (e *Service) boostWithPopularity(result *MBSearchResult) { rgMBIDs[i] = rg.MBID } - // Fetch popularity concurrently. + // Fetch popularity concurrently (2 POST calls). var ( - artistPop map[string]int - recordingPop map[string]int - rgPop map[string]int - wg sync.WaitGroup + recordingPopData map[string]PopularityData + rgPopData map[string]PopularityData + wg sync.WaitGroup ) - wg.Add(3) //nolint:mnd - - go func() { - defer wg.Done() - - pop, err := e.lb.ArtistPopularity(e.ctx, artistMBIDs) - if err != nil { - e.logger.Warn("popularity lookup failed", "entity", "artist", "error", err) - - return - } - - artistPop = pop - }() + wg.Add(2) //nolint:mnd go func() { defer wg.Done() @@ -1215,7 +1883,7 @@ func (e *Service) boostWithPopularity(result *MBSearchResult) { return } - recordingPop = pop + recordingPopData = pop }() go func() { @@ -1228,38 +1896,22 @@ func (e *Service) boostWithPopularity(result *MBSearchResult) { return } - rgPop = pop + rgPopData = pop }() wg.Wait() - // Build library MBID set for the library score bonus. - libMBIDs := make(map[string]bool) - - if artistPop != nil { - libraryCheck := e.libMBID.CheckMBIDs(artistMBIDs) - - for mbid, entityType := range libraryCheck { - if entityType == "artist" { - libMBIDs[mbid] = true - } - } + // Backfill index with popularity data for future searches. + if recordingPopData != nil { + go e.index.BackfillPopularity(recordingPopData) } - // Mark artists that have popularity data. - if artistPop != nil { - for i := range result.Artists { - if pop, ok := artistPop[result.Artists[i].MBID]; ok { - result.Artists[i].HasPopularity = true - result.Artists[i].Popularity = pop - } - } + if rgPopData != nil { + go e.index.BackfillPopularity(rgPopData) } - // Rerank each entity type. - rerankArtists(result.Artists, artistPop, libMBIDs) - rerankRecordings(result.Recordings, recordingPop) - rerankReleaseGroups(result.ReleaseGroups, rgPop) + rerankRecordings(result.Recordings, listenCounts(recordingPopData)) + rerankReleaseGroups(result.ReleaseGroups, listenCounts(rgPopData)) } // boostNameMatches re-sorts artists and release groups so that @@ -1351,7 +2003,7 @@ func (e *Service) disambiguateSameNameArtists(query string, artists []MBArtist) // Re-sort the same-name block by LB popularity descending. sort.SliceStable(artists[:sameNameEnd], func(i, j int) bool { - return pop[artists[i].MBID] > pop[artists[j].MBID] + return pop[artists[i].MBID].ListenCount > pop[artists[j].MBID].ListenCount }) } @@ -1414,94 +2066,1281 @@ func rgMatchTier(query, title, artistCredit string) int { // rerankArtists sorts artists by blended score and updates their // Score field to the new value (0–100 scale). func rerankArtists(artists []MBArtist, pop map[string]int, libraryMBIDs map[string]bool) { + rerankArtistsPersonalized(artists, pop, libraryMBIDs, nil) +} + +func rerankArtistsPersonalized(artists []MBArtist, pop map[string]int, inLib map[string]bool, simScores map[string]int) { if len(artists) == 0 { return } maxPop := maxListenCount(pop) + maxSim := maxSimScoreVal(simScores) sort.SliceStable(artists, func(i, j int) bool { - si := blendedScore(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop) - sj := blendedScore(float64(artists[j].Score)/100.0, pop[artists[j].MBID], maxPop) - - // Library boost as tiebreaker — library artists win ties. - if si == sj { - iLib := libraryMBIDs[artists[i].MBID] - jLib := libraryMBIDs[artists[j].MBID] - - if iLib != jLib { - return iLib - } - } - + si := blendedScoreFull(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop, personalScore(artists[i].MBID, inLib, simScores, maxSim)) + sj := blendedScoreFull(float64(artists[j].Score)/100.0, pop[artists[j].MBID], maxPop, personalScore(artists[j].MBID, inLib, simScores, maxSim)) return si > sj }) - // Update Score field. Library artists get a post-normalization - // bonus that doesn't pollute the maxPop denominator. for i := range artists { - s := blendedScore(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop) - score := int(s * 100) - - if libraryMBIDs[artists[i].MBID] { - score += libraryScoreBonus - } - - artists[i].Score = score + s := blendedScoreFull(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop, personalScore(artists[i].MBID, inLib, simScores, maxSim)) + artists[i].Score = int(s * 100) } } // rerankRecordings sorts recordings by blended score and updates // their Score field. func rerankRecordings(recordings []MBRecording, pop map[string]int) { + rerankRecordingsPersonalized(recordings, pop, nil, nil) +} + +func rerankRecordingsPersonalized(recordings []MBRecording, pop map[string]int, inLib map[string]bool, simScores map[string]int) { if len(recordings) == 0 { return } maxPop := maxListenCount(pop) + maxSim := maxSimScoreVal(simScores) sort.SliceStable(recordings, func(i, j int) bool { - si := blendedScore(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop) - sj := blendedScore(float64(recordings[j].Score)/100.0, pop[recordings[j].MBID], maxPop) - + si := blendedScoreFull(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop, personalScore(recordings[i].MBID, inLib, simScores, maxSim)) + sj := blendedScoreFull(float64(recordings[j].Score)/100.0, pop[recordings[j].MBID], maxPop, personalScore(recordings[j].MBID, inLib, simScores, maxSim)) return si > sj }) for i := range recordings { - s := blendedScore(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop) + s := blendedScoreFull(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop, personalScore(recordings[i].MBID, inLib, simScores, maxSim)) recordings[i].Score = int(s * 100) } } // rerankReleaseGroups sorts release groups by blended score -// (text relevance + popularity) and updates their Score field. +// (text relevance + popularity + personalization) and updates their Score field. func rerankReleaseGroups(rgs []MBReleaseGroup, pop map[string]int) { + rerankReleaseGroupsPersonalized(rgs, pop, nil, nil) +} + +func rerankReleaseGroupsPersonalized(rgs []MBReleaseGroup, pop map[string]int, inLib map[string]bool, simScores map[string]int) { if len(rgs) == 0 { return } maxPop := maxListenCount(pop) + maxSim := maxSimScoreVal(simScores) sort.SliceStable(rgs, func(i, j int) bool { - si := blendedScore(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop) - sj := blendedScore(float64(rgs[j].Score)/100.0, pop[rgs[j].MBID], maxPop) - + si := blendedScoreFull(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop, personalScore(rgs[i].MBID, inLib, simScores, maxSim)) + sj := blendedScoreFull(float64(rgs[j].Score)/100.0, pop[rgs[j].MBID], maxPop, personalScore(rgs[j].MBID, inLib, simScores, maxSim)) return si > sj }) for i := range rgs { - s := blendedScore(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop) + s := blendedScoreFull(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop, personalScore(rgs[i].MBID, inLib, simScores, maxSim)) rgs[i].Score = int(s * 100) } } +// maxSimScoreVal returns the highest similarity score in the map. +func maxSimScoreVal(scores map[string]int) int { + maxVal := 0 + for _, v := range scores { + if v > maxVal { + maxVal = v + } + } + + return maxVal +} + +// personalScore returns the personalization signal (0.0–1.0) for an MBID. +// Uses similarity scores from similar_artist_map, scaled by the max score +// in the batch so the most similar artist gets the full personalSimilar weight. +func personalScore(mbid string, inLib map[string]bool, simScores map[string]int, maxSimScore int) float64 { + if inLib[mbid] { + return personalInLibrary + } + + if score, ok := simScores[mbid]; ok && score > 0 && maxSimScore > 0 { + return personalSimilar * (float64(score) / float64(maxSimScore)) + } + + return 0.0 +} + + +// --------------------------------------------------------------------------- +// Top Results — intent-scored cards +// --------------------------------------------------------------------------- + +const ( + // topResultsMax is the maximum number of top-result cards to + // return. Bounded because they occupy expensive horizontal + // screen real estate above the main search lists. + topResultsMax = 5 + + // topResultsPerCatMax caps how many cards from a single + // category can appear in the final selection. Keeps the row + // from being all-artists or all-recordings on lopsided queries. + topResultsPerCatMax = 2 + + // topResultsMinScore is the absolute floor for a candidate's + // final score (quality * prior). Nothing below this survives, + // regardless of category or rank. + topResultsMinScore = 0.08 + + // topResultsCandidates is how many candidates per category + // feed into intent scoring. Larger = more chances to surface + // a better card, smaller = faster and less susceptible to + // main-rerank noise. + topResultsCandidates = 10 + + // topResultsExactScanLimit caps how deep into each main result + // list we'll scan for exact title/artist matches that didn't + // make the top-N rerank. This is the safety net for the case + // where MB returns dozens of identically-relevant candidates + // (covers of a popular song) and the rerank fails to surface + // the canonical version because its popularity isn't indexed. + topResultsExactScanLimit = 50 + + // topResultsExactCap is how many exact-match candidates per + // category can enter the candidate pool from the dedicated + // ExactMatches retrieval source. + topResultsExactCap = 3 + + // topResultsClickDecay is the half-life of a per-query click + // boost in days. Longer = stickier, shorter = more + // responsive to recent intent. + topResultsClickDecay = 30.0 + + // topResultsRowConfidence is the minimum gap between the + // winning category's intent prior and the runner-up before we + // show the row at all. Below this we hide the row entirely + // — it's better to show nothing than a wrong guess. + topResultsRowConfidence = 0.12 + + // Feature weights for candidate quality scoring. Sum is not + // required to be 1.0 because the final score is multiplied + // by the intent prior separately. Tune these against + // specific query cases that behave wrong. + fwExactTitle = 1.00 // normalized title matches query exactly + fwExactArtist = 0.90 // artist name matches query exactly + fwPrefixTitle = 0.60 // title starts with query + fwContainsWord = 0.40 // title contains query as a whole word + fwContainsAny = 0.20 // title has query as any substring + fwListenLog = 0.80 // log-scaled listen count (0 when 0 listens) + fwListenerLog = 0.60 // log-scaled listener count + fwInLibrary = 0.50 // owned by the user + fwSimilar = 0.20 // similar to an owned artist + fwClusterBig = 0.15 // release-group is a known canonical (many releases) + fwOfficialOnly = 0.10 // official-status release only (not a bootleg) + + // priorAlpha controls how much the intent prior influences + // final ranking. Higher values make category dominance + // more decisive; lower values let individual quality scores + // win across categories. + priorAlpha = 1.5 +) + +// resolveTopResults computes intent-scored top result cards from the +// already-reranked search results plus a dedicated exact-match +// retrieval source. Returns 0-5 cards sorted by final score +// descending. +// +// Pipeline: +// 1. Retrieve candidates from three sources: top-N per category from +// the main reranked result + exact title/artist matches from the +// local index. Union them, deduping by MBID. +// 2. Score each candidate using a featurized additive scorer with +// explicit named features. Quality is purely candidate-side; no +// cross-candidate normalization. +// 3. Compute a category intent prior from the catalog signals +// (listen-count distribution per category, query shape rules, +// exact-match counts). Multiply quality by prior^alpha. +// 4. Sort by final score, apply per-category caps and the row-level +// confidence threshold. Return up to topResultsMax cards. +func (e *Service) resolveTopResults(query string, result *MBSearchResult) []TopResult { + q := strings.ToLower(strings.TrimSpace(query)) + if q == "" { + return nil + } + + // Stage 1: gather candidates. + clicks := e.getSearchClicks(q) + exactMatches := e.index.ExactMatches(q, topResultsExactCap) + candidates := e.gatherTopCandidates(q, result, exactMatches, clicks) + if len(candidates) == 0 { + return nil + } + + // Identify candidates that hit an exact-match feature so the + // intent prior can boost their categories accordingly. This + // is what catches Blue October's "Calling You" — even if the + // local index never heard of Blue October, the MB result list + // has the recording with title == query, and the prior should + // know that strengthens the recording category. Composite + // matches (query contains both the title and the artist of a + // recording or album) are treated the same way. + var exactCandidates []topCandidate + for _, c := range candidates { + isExact := isExactNameMatch(q, c.topResult.Name) || + isExactNameMatch(q, c.topResult.ArtistCredit) || + isCompositeMatch(q, c.topResult.Name, c.topResult.ArtistCredit) + if isExact { + exactCandidates = append(exactCandidates, c) + } + } + + // Stage 2: compute the category intent prior. + prior := e.computeIntentPrior(q, result, exactMatches, exactCandidates) + + // Confidence gate: hide the row entirely if no category clearly + // dominates. Better to show nothing than a wrong guess. + // + // Override: if any candidate hits an exact match against an + // entity with non-zero listener count, the row should always + // show. An exact match is itself a confidence signal — even + // when shape and listener-distribution don't agree. + confident := priorConfidence(prior) >= topResultsRowConfidence + if !confident { + for _, c := range exactCandidates { + if c.qualityScore >= 1.0 { // exact match contributes >= fwExactTitle + confident = true + + break + } + } + } + + if !confident { + e.logger.Info("search top results: prior too flat, hiding row", + "query", query, + "candidates", len(candidates), + "exact_candidates", len(exactCandidates), + "prior_artist", prior.artist, + "prior_album", prior.album, + "prior_recording", prior.recording, + ) + + return nil + } + + // Stage 3: combine quality with prior. + for i := range candidates { + c := &candidates[i] + + var p float64 + + switch c.category { + case "artist": + p = prior.artist + case "release_group": + p = prior.album + case "recording": + p = prior.recording + } + + c.finalScore = c.qualityScore * math.Pow(p, priorAlpha) + } + + // Stage 4: sort, dedupe by MBID, apply caps. + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].finalScore > candidates[j].finalScore + }) + + catCount := make(map[string]int, 3) //nolint:mnd + seen := make(map[string]bool, len(candidates)) + + var selected []TopResult + + for _, c := range candidates { + if len(selected) >= topResultsMax { + break + } + + if c.finalScore < topResultsMinScore { + break + } + + if catCount[c.category] >= topResultsPerCatMax { + continue + } + + if seen[c.topResult.MBID] { + continue + } + + c.topResult.IntentScore = c.finalScore + selected = append(selected, c.topResult) + catCount[c.category]++ + seen[c.topResult.MBID] = true + } + + if len(selected) > 0 { + topName := selected[0].Name + if selected[0].ArtistCredit != "" { + topName = topName + " — " + selected[0].ArtistCredit + } + + e.logger.Info("search top results selected", + "query", query, + "count", len(selected), + "candidates", len(candidates), + "exact_candidates", len(exactCandidates), + "prior_artist", prior.artist, + "prior_album", prior.album, + "prior_recording", prior.recording, + "top", topName, + "top_score", selected[0].IntentScore, + ) + } + + return selected +} + +// topCandidate is a single scored candidate flowing through the +// top-results pipeline. qualityScore is the per-candidate signal +// without category bias; finalScore is qualityScore multiplied by +// the category prior at selection time. +type topCandidate struct { + topResult TopResult + category string + qualityScore float64 + finalScore float64 +} + +// intentPrior is a probability distribution over the three entity +// categories: how likely the user is searching for an artist, an +// album, or a recording. Sums to 1.0. +type intentPrior struct { + artist float64 + album float64 + recording float64 +} + +// gatherTopCandidates builds the candidate pool from the top-N +// per category of the main reranked result plus exact-match results +// from two sources: the dedicated local-index ExactMatches lookup +// and any results in the MB list whose title/artist exactly equal +// the query. Each candidate is scored once with the featurized +// quality scorer. Duplicates (same MBID) are deduped, keeping the +// highest quality score. +func (e *Service) gatherTopCandidates( + q string, + result *MBSearchResult, + exactMatches []SearchIndexResult, + clicks map[string]searchClick, +) []topCandidate { + candidates := make([]topCandidate, 0, topResultsCandidates*3+len(exactMatches)) + byMBID := make(map[string]int, cap(candidates)) + + add := func(cand topCandidate) { + if cand.topResult.MBID == "" { + return + } + + if existing, ok := byMBID[cand.topResult.MBID]; ok { + if cand.qualityScore > candidates[existing].qualityScore { + candidates[existing] = cand + } + + return + } + + byMBID[cand.topResult.MBID] = len(candidates) + candidates = append(candidates, cand) + } + + // Source 1: top-N artists from the main rerank. + limit := topResultsCandidates + if limit > len(result.Artists) { + limit = len(result.Artists) + } + + for i := 0; i < limit; i++ { + a := result.Artists[i] + + quality := e.scoreArtistCandidate(q, &a, clicks) + add(topCandidate{ + topResult: TopResult{ + EntityType: "artist", + MBID: a.MBID, + Name: a.Name, + ArtistType: a.Type, + Country: a.Country, + InLibrary: a.InLibrary, + }, + category: "artist", + qualityScore: quality, + }) + } + + // Source 1b: scan the entire artist list (capped at + // topResultsExactScanLimit) for exact name matches that didn't + // make the top-N rerank. Without this, an artist with a + // perfect name match buried at position 12 by the rerank + // could never become a top-result candidate. + scanLimit := topResultsExactScanLimit + if scanLimit > len(result.Artists) { + scanLimit = len(result.Artists) + } + + for i := topResultsCandidates; i < scanLimit; i++ { + a := result.Artists[i] + if !isExactNameMatch(q, a.Name) { + continue + } + + quality := e.scoreArtistCandidate(q, &a, clicks) + add(topCandidate{ + topResult: TopResult{ + EntityType: "artist", + MBID: a.MBID, + Name: a.Name, + ArtistType: a.Type, + Country: a.Country, + InLibrary: a.InLibrary, + }, + category: "artist", + qualityScore: quality, + }) + } + + // Source 2: top-N release groups from the main rerank. + limit = topResultsCandidates + if limit > len(result.ReleaseGroups) { + limit = len(result.ReleaseGroups) + } + + for i := 0; i < limit; i++ { + rg := result.ReleaseGroups[i] + + quality := e.scoreReleaseGroupCandidate(q, &rg, clicks) + year := "" + if len(rg.FirstReleaseDate) >= 4 { //nolint:mnd + year = rg.FirstReleaseDate[:4] + } + + add(topCandidate{ + topResult: TopResult{ + EntityType: "release_group", + MBID: rg.MBID, + Name: rg.Title, + ArtistCredit: rg.ArtistCredit, + PrimaryType: rg.PrimaryType, + Year: year, + InLibrary: rg.InLibrary, + }, + category: "release_group", + qualityScore: quality, + }) + } + + // Source 2b: scan the rest of the release-group list for + // exact title or artist matches. Same rationale as Source 1b. + // Also catches composite matches (e.g. "abbey road beatles"). + scanLimit = topResultsExactScanLimit + if scanLimit > len(result.ReleaseGroups) { + scanLimit = len(result.ReleaseGroups) + } + + for i := topResultsCandidates; i < scanLimit; i++ { + rg := result.ReleaseGroups[i] + + exactTitle := isExactNameMatch(q, rg.Title) + exactArtist := isExactNameMatch(q, rg.ArtistCredit) + composite := isCompositeMatch(q, rg.Title, rg.ArtistCredit) + + if !exactTitle && !exactArtist && !composite { + continue + } + + quality := e.scoreReleaseGroupCandidate(q, &rg, clicks) + if composite && !exactTitle && !exactArtist { + quality += fwExactTitle + } + + year := "" + if len(rg.FirstReleaseDate) >= 4 { //nolint:mnd + year = rg.FirstReleaseDate[:4] + } + + add(topCandidate{ + topResult: TopResult{ + EntityType: "release_group", + MBID: rg.MBID, + Name: rg.Title, + ArtistCredit: rg.ArtistCredit, + PrimaryType: rg.PrimaryType, + Year: year, + InLibrary: rg.InLibrary, + }, + category: "release_group", + qualityScore: quality, + }) + } + + // Source 3: top-N recordings from the main rerank. + limit = topResultsCandidates + if limit > len(result.Recordings) { + limit = len(result.Recordings) + } + + for i := 0; i < limit; i++ { + r := result.Recordings[i] + + quality := e.scoreRecordingCandidate(q, &r, clicks) + add(topCandidate{ + topResult: TopResult{ + EntityType: "recording", + MBID: r.MBID, + Name: r.Title, + ArtistCredit: r.ArtistCredit, + Length: r.Length, + InLibrary: r.InLibrary, + }, + category: "recording", + qualityScore: quality, + }) + } + + // Source 3b: scan the rest of the recording list for exact + // matches. This is the critical fix for the case where MB + // returns 75 recordings all with relevance 100 — the rerank + // can only differentiate them by popularity (which may be + // missing for many), so a popular exact match like Blue + // October's "Calling You" might land at position 11+. By + // scanning the full list for exact matches, we surface them + // regardless of where the rerank put them. + // + // Also catches "composite" matches: when the query contains + // both the recording title AND the artist credit (e.g. + // "calling you blue october"), the recording is a strong + // candidate even though neither field equals the full query. + scanLimit = topResultsExactScanLimit + if scanLimit > len(result.Recordings) { + scanLimit = len(result.Recordings) + } + + for i := topResultsCandidates; i < scanLimit; i++ { + r := result.Recordings[i] + + exactTitle := isExactNameMatch(q, r.Title) + exactArtist := isExactNameMatch(q, r.ArtistCredit) + composite := isCompositeMatch(q, r.Title, r.ArtistCredit) + + if !exactTitle && !exactArtist && !composite { + continue + } + + quality := e.scoreRecordingCandidate(q, &r, clicks) + + // Composite matches don't get the exact-title feature + // from the scorer (because neither field equals the + // query), so add the bonus explicitly here so they + // compete with title-only exact matches. + if composite && !exactTitle && !exactArtist { + quality += fwExactTitle + } + + add(topCandidate{ + topResult: TopResult{ + EntityType: "recording", + MBID: r.MBID, + Name: r.Title, + ArtistCredit: r.ArtistCredit, + Length: r.Length, + InLibrary: r.InLibrary, + }, + category: "recording", + qualityScore: quality, + }) + } + + // Source 4: exact matches from the local index. These bypass + // the main rerank entirely so a high-popularity entity buried + // at position 8 in the MB result list still gets surfaced. + for _, m := range exactMatches { + quality := e.scoreExactMatch(q, &m, clicks) + + switch m.EntityType { + case "artist": + add(topCandidate{ + topResult: TopResult{ + EntityType: "artist", + MBID: m.MBID, + Name: m.Title, + ArtistType: m.ArtistType, + Country: m.Country, + InLibrary: m.InLibrary || m.LocalArtistID > 0, + }, + category: "artist", + qualityScore: quality, + }) + case "release_group": + year := "" + if len(m.ReleaseDate) >= 4 { //nolint:mnd + year = m.ReleaseDate[:4] + } + + add(topCandidate{ + topResult: TopResult{ + EntityType: "release_group", + MBID: m.MBID, + Name: m.Title, + ArtistCredit: m.ArtistName, + PrimaryType: m.PrimaryType, + Year: year, + InLibrary: m.InLibrary || m.LocalReleaseGroupID > 0, + }, + category: "release_group", + qualityScore: quality, + }) + case "recording": + add(topCandidate{ + topResult: TopResult{ + EntityType: "recording", + MBID: m.MBID, + Name: m.Title, + ArtistCredit: m.ArtistName, + Length: m.Duration, + InLibrary: m.InLibrary || m.LocalRecordingID > 0, + }, + category: "recording", + qualityScore: quality, + }) + } + } + + return candidates +} + +// scoreArtistCandidate computes the featurized quality score for an +// artist top-result candidate. Pure additive — no cross-candidate +// normalization, no popularity squaring. +func (e *Service) scoreArtistCandidate( + q string, + a *MBArtist, + clicks map[string]searchClick, +) float64 { + name := strings.ToLower(a.Name) + qn := normalizeForMatch(q) + nn := normalizeForMatch(a.Name) + + score := 0.0 + + switch { + case nn == qn: + score += fwExactTitle + case strings.HasPrefix(name, q): + score += fwPrefixTitle + case containsWord(name, q): + score += fwContainsWord + case strings.Contains(name, q): + score += fwContainsAny + } + + score += fwListenLog * normLog(a.Popularity) + score += fwListenerLog * normLog(a.ListenerCount) + + if a.InLibrary { + score += fwInLibrary + } + + if cb := clicks[a.MBID]; cb.count > 0 { + score += clickFeature(cb) + } + + return score +} + +// scoreReleaseGroupCandidate computes the featurized quality score +// for a release-group top-result candidate. +func (e *Service) scoreReleaseGroupCandidate( + q string, + rg *MBReleaseGroup, + clicks map[string]searchClick, +) float64 { + title := strings.ToLower(rg.Title) + credit := strings.ToLower(rg.ArtistCredit) + qn := normalizeForMatch(q) + tn := normalizeForMatch(rg.Title) + cn := normalizeForMatch(rg.ArtistCredit) + + score := 0.0 + + switch { + case tn == qn: + score += fwExactTitle + case cn == qn && len(qn) >= 3: //nolint:mnd + score += fwExactArtist + case strings.HasPrefix(title, q): + score += fwPrefixTitle + case containsWord(title, q): + score += fwContainsWord + case strings.Contains(title, q): + score += fwContainsAny + } + + score += fwListenLog * normLog(rg.Popularity) + score += fwListenerLog * normLog(rg.ListenerCount) + + if rg.InLibrary { + score += fwInLibrary + } + + // Penalize "Various Artists" compilations — they tend to dominate + // covers searches without being what the user wants. + if strings.Contains(credit, "various artists") { + score *= 0.5 //nolint:mnd + } + + if cb := clicks[rg.MBID]; cb.count > 0 { + score += clickFeature(cb) + } + + return score +} + +// scoreRecordingCandidate computes the featurized quality score for +// a recording top-result candidate. +func (e *Service) scoreRecordingCandidate( + q string, + r *MBRecording, + clicks map[string]searchClick, +) float64 { + title := strings.ToLower(r.Title) + qn := normalizeForMatch(q) + tn := normalizeForMatch(r.Title) + cn := normalizeForMatch(r.ArtistCredit) + + score := 0.0 + + switch { + case tn == qn: + score += fwExactTitle + case cn == qn && len(qn) >= 3: //nolint:mnd + score += fwExactArtist + case strings.HasPrefix(title, q): + score += fwPrefixTitle + case containsWord(title, q): + score += fwContainsWord + case strings.Contains(title, q): + score += fwContainsAny + } + + score += fwListenLog * normLog(r.Popularity) + score += fwListenerLog * normLog(r.ListenerCount) + + if r.InLibrary { + score += fwInLibrary + } + + if cb := clicks[r.MBID]; cb.count > 0 { + score += clickFeature(cb) + } + + return score +} + +// scoreExactMatch computes a featurized quality score for a +// candidate sourced from ExactMatches. Always assigns the exact +// match feature bonus on top of the standard quality features so +// that exact matches reliably outrank fuzzy ones. +func (e *Service) scoreExactMatch( + q string, + m *SearchIndexResult, + clicks map[string]searchClick, +) float64 { + title := strings.ToLower(m.Title) + credit := strings.ToLower(m.ArtistName) + + score := 0.0 + + if title == q { + score += fwExactTitle + } else if credit == q { + score += fwExactArtist + } else { + // Shouldn't happen — ExactMatches only returns rows whose + // title or artist matches. Defensive fallback. + score += fwContainsWord + } + + score += fwListenLog * normLog(m.Popularity) + score += fwListenerLog * normLog(m.ListenerCount) + + if m.InLibrary || m.LocalArtistID > 0 || m.LocalReleaseGroupID > 0 || m.LocalRecordingID > 0 { + score += fwInLibrary + } + + if cb := clicks[m.MBID]; cb.count > 0 { + score += clickFeature(cb) + } + + return score +} + +// computeIntentPrior derives a category probability distribution +// from the query shape and the catalog signals available in the +// candidate pool. Returns weights summing to ~1.0. +// +// Strategy: start with a uniform prior, then apply signal-based +// adjustments. The strongest signals (exact name match against a +// popular artist, dominant track-cover-wave pattern) bias the prior +// hard; weaker signals (query length, listen-count distribution) +// nudge it. Finally normalize to a probability distribution. +// +// The exactCandidates parameter is the list of candidates that hit +// an exact-match feature (either via the local index ExactMatches +// retrieval or via the MB result-list scan in gatherTopCandidates). +// These provide the strongest evidence we have for "the user means +// this category" and dominate weaker signals. +func (e *Service) computeIntentPrior( + q string, + result *MBSearchResult, + exactMatches []SearchIndexResult, + exactCandidates []topCandidate, +) intentPrior { + // Start with a slight lean toward recordings — most music + // searches in practice are for songs. Mild enough that + // other signals can override. + weights := intentPrior{ + artist: 1.0, + album: 1.0, + recording: 1.2, //nolint:mnd + } + + // Signal: query length (word count). Single-word queries skew + // strongly artist; long queries skew strongly toward + // titles (album or recording). + wordCount := len(strings.Fields(q)) + + switch { + case wordCount == 1: + weights.artist *= 2.0 //nolint:mnd + weights.album *= 0.7 //nolint:mnd + weights.recording *= 0.7 //nolint:mnd + case wordCount >= 4: //nolint:mnd + weights.artist *= 0.5 //nolint:mnd + weights.album *= 1.2 //nolint:mnd + weights.recording *= 1.3 //nolint:mnd + } + + // Signal: exact matches in the local index. An exact match + // against a popular artist is the strongest evidence we + // have for "the user means this artist". Scale by listener + // count so a popular exact match dominates and an obscure + // one doesn't move the needle. + for _, m := range exactMatches { + if !isExactNameMatch(q, m.Title) && !isExactNameMatch(q, m.ArtistName) { + continue + } + + // Confidence boost scales with log listener count. + boost := 1.0 + 1.5*normLog(m.ListenerCount) //nolint:mnd + + switch m.EntityType { + case "artist": + weights.artist *= boost + case "release_group": + weights.album *= boost + case "recording": + weights.recording *= boost + } + } + + // Signal: exact-match candidates discovered in the MB result + // list (Source 1b/2b/3b in gatherTopCandidates). These cover + // the case where the local index doesn't have the entity but + // MB does — e.g. Blue October's "Calling You" when Blue + // October isn't yet a known artist. Same scaling as + // index-sourced exact matches. + for _, c := range exactCandidates { + var listeners int + switch c.category { + case "artist": + listeners = artistListenerByMBID(result.Artists, c.topResult.MBID) + case "release_group": + listeners = rgListenerByMBID(result.ReleaseGroups, c.topResult.MBID) + case "recording": + listeners = recListenerByMBID(result.Recordings, c.topResult.MBID) + } + + boost := 1.0 + 1.0*normLog(listeners) //nolint:mnd + + switch c.category { + case "artist": + weights.artist *= boost + case "release_group": + weights.album *= boost + case "recording": + weights.recording *= boost + } + } + + // Signal: many recordings in the result list with the same + // title as the query → cover-wave pattern → strong recording. + titleMatches := 0 + for _, r := range result.Recordings { + if isExactNameMatch(q, r.Title) { + titleMatches++ + } + } + + if titleMatches >= 5 { //nolint:mnd + weights.recording *= 1.8 //nolint:mnd + } else if titleMatches >= 2 { //nolint:mnd + weights.recording *= 1.3 //nolint:mnd + } + + // Signal: aggregate listener count per category in the + // candidate pool. Sum the top 5 per category and use the + // proportional split as a soft nudge. Recordings naturally + // have higher listen counts than albums (each play increments + // the recording, not the album), so we use *listener* count + // rather than *listen* count to dampen that bias. + artistListeners := sumTopListeners(artistListenerCounts(result.Artists), 5) //nolint:mnd + albumListeners := sumTopListeners(rgListenerCounts(result.ReleaseGroups), 5) //nolint:mnd + recListeners := sumTopListeners(recListenerCounts(result.Recordings), 5) //nolint:mnd + + totalListeners := artistListeners + albumListeners + recListeners + if totalListeners > 0 { + // Apply as a 0.5x nudge so it doesn't override stronger + // signals. We'd rather trust shape and exact matches + // than raw listener distributions. + weights.artist *= 1.0 + 0.5*float64(artistListeners)/float64(totalListeners) //nolint:mnd + weights.album *= 1.0 + 0.5*float64(albumListeners)/float64(totalListeners) //nolint:mnd + weights.recording *= 1.0 + 0.5*float64(recListeners)/float64(totalListeners) //nolint:mnd + } + + // Normalize to a probability distribution. + total := weights.artist + weights.album + weights.recording + if total <= 0 { + return intentPrior{artist: 1.0 / 3.0, album: 1.0 / 3.0, recording: 1.0 / 3.0} //nolint:mnd + } + + return intentPrior{ + artist: weights.artist / total, + album: weights.album / total, + recording: weights.recording / total, + } +} + +// artistListenerByMBID returns the listener count for the artist +// with the given MBID, or 0 when not found. +func artistListenerByMBID(arts []MBArtist, mbid string) int { + for _, a := range arts { + if a.MBID == mbid { + return a.ListenerCount + } + } + + return 0 +} + +func rgListenerByMBID(rgs []MBReleaseGroup, mbid string) int { + for _, rg := range rgs { + if rg.MBID == mbid { + return rg.ListenerCount + } + } + + return 0 +} + +func recListenerByMBID(recs []MBRecording, mbid string) int { + for _, r := range recs { + if r.MBID == mbid { + return r.ListenerCount + } + } + + return 0 +} + +// priorConfidence returns the difference between the largest and +// second-largest values in the prior, as a quick proxy for "how +// sure is the prior about its top pick". Range is 0 (totally flat, +// i.e. uniform 1/3) to 1 (one category at 1.0, others at 0). +func priorConfidence(p intentPrior) float64 { + vals := [3]float64{p.artist, p.album, p.recording} + + maxVal := vals[0] + for _, v := range vals[1:] { + if v > maxVal { + maxVal = v + } + } + + secondMax := 0.0 + for _, v := range vals { + if v < maxVal && v > secondMax { + secondMax = v + } + } + + return maxVal - secondMax +} + +// normLog returns log10(n+1) / log10(maxScale+1), clamped to [0, 1]. +// maxScale is a fixed reference point so the function is stable +// across queries — different from popRank which normalizes to a +// dynamic per-query max. +func normLog(n int) float64 { + if n <= 0 { + return 0 + } + + const maxScale = 50_000_000 // top-tier artists have ~10-150M listens + + v := math.Log10(float64(n)+1) / math.Log10(maxScale+1) //nolint:mnd + if v > 1.0 { + return 1.0 + } + + return v +} + +// clickFeature returns the additive feature contribution from a +// per-query click record. Bounded so a click streak can't +// dominate the rest of the score. +func clickFeature(c searchClick) float64 { + daysSince := time.Since(c.lastClicked).Hours() / 24.0 //nolint:mnd + recency := 1.0 / (1.0 + daysSince/topResultsClickDecay) + boost := math.Log2(float64(c.count)+1) * recency * 0.3 //nolint:mnd + + if boost > 0.6 { //nolint:mnd + return 0.6 + } + + return boost +} + +// artistListenerCounts and friends extract the per-entity listener +// count slice for the listener-distribution prior signal. +func artistListenerCounts(arts []MBArtist) []int { + out := make([]int, len(arts)) + for i, a := range arts { + out[i] = a.ListenerCount + } + + return out +} + +func rgListenerCounts(rgs []MBReleaseGroup) []int { + out := make([]int, len(rgs)) + for i, rg := range rgs { + out[i] = rg.ListenerCount + } + + return out +} + +func recListenerCounts(recs []MBRecording) []int { + out := make([]int, len(recs)) + for i, r := range recs { + out[i] = r.ListenerCount + } + + return out +} + +// sumTopListeners returns the sum of the top n entries in xs. +// Used by the listener-distribution prior signal. +func sumTopListeners(xs []int, n int) int { + if len(xs) == 0 { + return 0 + } + + sorted := make([]int, len(xs)) + copy(sorted, xs) + + sort.Slice(sorted, func(i, j int) bool { + return sorted[i] > sorted[j] + }) + + if n > len(sorted) { + n = len(sorted) + } + + sum := 0 + for i := 0; i < n; i++ { + sum += sorted[i] + } + + return sum +} +// containsWord checks if text contains word as a whole word bounded +// by spaces, hyphens, or string boundaries. +func containsWord(text, word string) bool { + idx := strings.Index(text, word) + if idx < 0 { + return false + } + + // Check left boundary. + if idx > 0 { + c := text[idx-1] + if c != ' ' && c != '-' && c != '(' && c != '[' { + return false + } + } + + // Check right boundary. + end := idx + len(word) + if end < len(text) { + c := text[end] + if c != ' ' && c != '-' && c != ')' && c != ']' { + return false + } + } + + return true +} + +// isExactNameMatch returns true when the (already lowercased) query +// is equal to the (raw-cased) name after lowercasing and trimming. +// Punctuation is normalized so "Party in the U.S.A." matches +// "party in the usa". Used by the top-results pipeline to find +// exact matches anywhere in the main result lists, not just in the +// top-N positions the main rerank produced. +func isExactNameMatch(q, name string) bool { + if name == "" { + return false + } + + return normalizeForMatch(name) == normalizeForMatch(q) +} + +// isCompositeMatch returns true when the query contains both `title` +// and `artist` as normalized substrings — e.g. "calling you blue +// october" composes "calling you" + "blue october" so the user +// probably wants Blue October's "Calling You". Both fragments must +// be at least 3 characters to be considered. +// +// This is the heuristic version of entity linking: instead of +// training a model to identify "title + artist" multi-entity +// queries, we just notice when a candidate's title and artist both +// appear inside the user's query. +func isCompositeMatch(q, title, artist string) bool { + if len(title) < 3 || len(artist) < 3 { //nolint:mnd + return false + } + + qn := normalizeForMatch(q) + tn := normalizeForMatch(title) + an := normalizeForMatch(artist) + + if tn == "" || an == "" || qn == "" { + return false + } + + // Both fragments must appear in the query. Order doesn't + // matter — "calling you blue october" and "blue october + // calling you" should both match. + return strings.Contains(qn, tn) && strings.Contains(qn, an) +} + +// normalizeForMatch lowercases, trims, and strips ASCII punctuation +// other than internal whitespace so titles like "Party in the U.S.A.", +// "Party In the U.S.A", and "party in the usa" all collapse to the +// same normalized form. Cheap O(n) — no regex. +func normalizeForMatch(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + + var b strings.Builder + b.Grow(len(s)) + + prevSpace := false + + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= '0' && r <= '9', + r >= 0x80: // keep non-ASCII as-is + b.WriteRune(r) + prevSpace = false + case r == ' ' || r == '\t': + if !prevSpace && b.Len() > 0 { + b.WriteByte(' ') + prevSpace = true + } + default: + // Drop punctuation entirely (not even replaced with + // a space). This collapses "U.S.A." to "usa" so it + // matches the dot-less form. + } + } + + out := b.String() + if prevSpace && len(out) > 0 { + out = out[:len(out)-1] + } + + return out +} + +type searchClick struct { + count int + lastClicked time.Time +} + +// getSearchClicks returns click history for a query. +func (e *Service) getSearchClicks(query string) map[string]searchClick { + rows, err := e.db.QueryContext( + "SELECT entity_mbid, click_count, last_clicked FROM search_clicks WHERE query = ?", + query, + ) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + result := make(map[string]searchClick) + + for rows.Next() { + var mbid string + var count int + var lastClicked time.Time + + if err := rows.Scan(&mbid, &count, &lastClicked); err == nil { + result[mbid] = searchClick{count: count, lastClicked: lastClicked} + } + } + + return result +} + +// RecordSearchClick records that the user clicked a search result. +// Called from the frontend when any search result is clicked. +func (e *Service) RecordSearchClick(query, mbid, entityType string) { + if query == "" || mbid == "" { + return + } + + q := strings.ToLower(strings.TrimSpace(query)) + + _, _ = e.db.ExecContext(` + INSERT INTO search_clicks (query, entity_mbid, entity_type, click_count, last_clicked) + VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(query, entity_mbid) DO UPDATE SET + click_count = click_count + 1, + last_clicked = CURRENT_TIMESTAMP + `, q, mbid, entityType) +} + // blendedScore computes relevanceWeight*relevance + popularityWeight*logPop. // relevance is 0–1. listenCount is raw; maxListenCount is the // maximum in the result set (for normalization). func blendedScore(relevance float64, listenCount, maxListenCount int) float64 { - // Use a floor for maxListenCount so that zero-popularity artists - // don't get a free pass when no result has popularity data. - // 100K is a reasonable "average popular artist" reference point. + return blendedScoreFull(relevance, listenCount, maxListenCount, 0.0) +} + +// blendedScoreFull computes the weighted blend of relevance, popularity, +// and personalization. personalization is 0.0–1.0. +func blendedScoreFull(relevance float64, listenCount, maxListenCount int, personalization float64) float64 { effectiveMax := maxListenCount if effectiveMax < 100_000 { //nolint:mnd effectiveMax = 100_000 @@ -1509,7 +3348,31 @@ func blendedScore(relevance float64, listenCount, maxListenCount int) float64 { logPop := math.Log10(float64(listenCount)+1) / math.Log10(float64(effectiveMax)+1) - return relevanceWeight*relevance + popularityWeight*logPop + return relevanceWeight*relevance + popularityWeight*logPop + personalizationWeight*personalization +} + +// dynamicSearchLimit computes the number of results to request from +// MB based on the total match count. Returns at least mbSearchLimit +// and at most mbSearchMaxLimit. Aims for ~15% of total matches so +// the ranking pipeline has enough candidates to surface popular +// results that MB's text relevance alone would bury. +func dynamicSearchLimit(totalMatches int) int { + if totalMatches <= mbSearchLimit { + return mbSearchLimit + } + + // 15% of total matches, but floor to mbSearchLimit and + // cap to mbSearchMaxLimit (and MB's API max of 100). + want := totalMatches * 15 / 100 //nolint:mnd + if want < mbSearchLimit { + want = mbSearchLimit + } + + if want > mbSearchMaxLimit { + want = mbSearchMaxLimit + } + + return want } // maxListenCount returns the highest listen count in the map. @@ -1525,6 +3388,16 @@ func maxListenCount(pop map[string]int) int { return maxVal } +// listenCounts extracts a simple mbid→listenCount map from PopularityData. +func listenCounts(pop map[string]PopularityData) map[string]int { + out := make(map[string]int, len(pop)) + for mbid, d := range pop { + out[mbid] = d.ListenCount + } + + return out +} + // --------------------------------------------------------------------------- // Lucene query building // --------------------------------------------------------------------------- @@ -1596,3 +3469,30 @@ func buildLuceneQuery(input string) string { return b.String() } + +// buildLuceneQueryWithArtist builds a Lucene query that searches +// both the entity's own field (title) and the artist credit field. +// For "queen": (releasegroup:queen* OR artist:queen*) +// This ensures searches return results BY the artist, not just +// results with the query in the title. +func buildLuceneQueryWithArtist(input, entityField, artistField string) string { + words := strings.Fields(strings.TrimSpace(input)) + if len(words) == 0 { + return "" + } + + for i, w := range words { + words[i] = luceneSpecialChars.Replace(w) + } + + // Build the base query terms. + base := buildLuceneQuery(input) + + // Single word: (field:word* OR artist:word*) + if len(words) == 1 { + return "(" + entityField + ":" + base + " OR " + artistField + ":" + base + ")" + } + + // Multi-word: (field:(term1 AND term2*) OR artist:(term1 AND term2*)) + return "(" + entityField + ":(" + base + ") OR " + artistField + ":(" + base + "))" +} diff --git a/backend/explore/librarymbid.go b/backend/explore/librarymbid.go index ac368bb..ad23650 100644 --- a/backend/explore/librarymbid.go +++ b/backend/explore/librarymbid.go @@ -1,6 +1,8 @@ package explore import ( + "strings" + "yellowjacket/backend/database" ) @@ -26,32 +28,58 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string { result := make(map[string]string, len(mbids)) - // Check each table. For a small number of MBIDs this is fine. - // For bulk checks we'd use a temp table join, but search results - // are capped at ~30 MBIDs total. - for _, mbid := range mbids { - if mbid == "" { + // Batch check all MBIDs against each table with a single IN query. + type tableEntity struct { + table string + entityType string + } + + tables := []tableEntity{ + {"artists", "artist"}, + {"release_groups", "release_group"}, + {"recordings", "recording"}, + } + + // Build a set of MBIDs still unresolved. + remaining := make(map[string]bool, len(mbids)) + for _, m := range mbids { + if m != "" { + remaining[m] = true + } + } + + for _, te := range tables { + if len(remaining) == 0 { + break + } + + // Build IN clause from remaining MBIDs. + placeholders := make([]string, 0, len(remaining)) + args := make([]any, 0, len(remaining)) + + for m := range remaining { + placeholders = append(placeholders, "?") + args = append(args, m) + } + + //nolint:gosec // table name is hardcoded from the tables slice above + query := "SELECT mbid FROM " + te.table + " WHERE mbid IN (" + + strings.Join(placeholders, ",") + ")" + + rows, err := idx.db.QueryContext(query, args...) + if err != nil { continue } - // Check artists. - if idx.exists("artists", mbid) { - result[mbid] = "artist" - - continue + for rows.Next() { + var mbid string + if err := rows.Scan(&mbid); err == nil { + result[mbid] = te.entityType + delete(remaining, mbid) + } } - // Check release groups. - if idx.exists("release_groups", mbid) { - result[mbid] = "release_group" - - continue - } - - // Check recordings. - if idx.exists("recordings", mbid) { - result[mbid] = "recording" - } + _ = rows.Close() } return result diff --git a/backend/explore/listenbrainz.go b/backend/explore/listenbrainz.go index 01268c8..b97b1ab 100644 --- a/backend/explore/listenbrainz.go +++ b/backend/explore/listenbrainz.go @@ -191,6 +191,17 @@ func (c *ListenBrainzClient) SimilarArtists( } } + // Sort by similarity score descending (most similar first). + slices.SortFunc(out, func(a, b LBSimilarArtist) int { + if a.Score > b.Score { + return -1 + } + if a.Score < b.Score { + return 1 + } + return 0 + }) + c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist") return out, nil @@ -212,11 +223,11 @@ type lbPopularityResult struct { } // ArtistPopularity fetches total listen counts for a batch of -// artist MBIDs. Returns a map[mbid]→listenCount. Artists with +// artist MBIDs. Returns a map[mbid]→PopularityData. Artists with // null counts (unknown to LB) are omitted from the map. func (c *ListenBrainzClient) ArtistPopularity( ctx context.Context, mbids []string, -) (map[string]int, error) { +) (map[string]PopularityData, error) { if len(mbids) == 0 { return nil, nil //nolint:nilnil } @@ -225,7 +236,7 @@ func (c *ListenBrainzClient) ArtistPopularity( cacheKey := "lb:pop:artist:" + hashMBIDs(mbids) if data, ok := c.cache.Get(cacheKey); ok { - var out map[string]int + var out map[string]PopularityData if err := json.Unmarshal(data, &out); err == nil { return out, nil } @@ -247,7 +258,7 @@ func (c *ListenBrainzClient) ArtistPopularity( // recording MBIDs. Returns a map[mbid]→listenCount. func (c *ListenBrainzClient) RecordingPopularity( ctx context.Context, mbids []string, -) (map[string]int, error) { +) (map[string]PopularityData, error) { if len(mbids) == 0 { return nil, nil //nolint:nilnil } @@ -256,7 +267,7 @@ func (c *ListenBrainzClient) RecordingPopularity( cacheKey := "lb:pop:recording:" + hashMBIDs(mbids) if data, ok := c.cache.Get(cacheKey); ok { - var out map[string]int + var out map[string]PopularityData if err := json.Unmarshal(data, &out); err == nil { return out, nil } @@ -278,7 +289,7 @@ func (c *ListenBrainzClient) RecordingPopularity( // release group MBIDs. Returns a map[mbid]→listenCount. func (c *ListenBrainzClient) ReleaseGroupPopularity( ctx context.Context, mbids []string, -) (map[string]int, error) { +) (map[string]PopularityData, error) { if len(mbids) == 0 { return nil, nil //nolint:nilnil } @@ -287,7 +298,7 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity( cacheKey := "lb:pop:release-group:" + hashMBIDs(mbids) if data, ok := c.cache.Get(cacheKey); ok { - var out map[string]int + var out map[string]PopularityData if err := json.Unmarshal(data, &out); err == nil { return out, nil } @@ -305,24 +316,116 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity( }) } +// ArtistMetadata holds the fields we extract from LB's batch +// /1/metadata/artist/ endpoint. Missing fields: aliases, +// disambiguation, sort_name (those come from MB per-artist). +type ArtistMetadata struct { + MBID string + Name string + Type string // "Group", "Person", etc + Country string // from "area" field + BeginYear int + EndYear int + WikidataQID string // extracted from rels +} + +// BatchArtistMetadata fetches metadata for up to ~1000 artist MBIDs +// in a single GET request to LB's /1/metadata/artist/ endpoint. +// Returns a map of mbid → ArtistMetadata. MBIDs with no metadata +// are omitted from the result. +func (c *ListenBrainzClient) BatchArtistMetadata( + ctx context.Context, mbids []string, +) (map[string]ArtistMetadata, error) { + if len(mbids) == 0 { + return nil, nil //nolint:nilnil + } + + url := listenBrainzBaseURL + "/1/metadata/artist/?artist_mbids=" + strings.Join(mbids, ",") + cacheKey := "lb:meta:artist:" + hashMBIDs(mbids) + + if data, ok := c.cache.Get(cacheKey); ok { + var out map[string]ArtistMetadata + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("batch artist metadata: %w", err) + } + + var raw []struct { + ArtistMBID string `json:"artist_mbid"` + MBID string `json:"mbid"` + Name string `json:"name"` + Type string `json:"type"` + Area string `json:"area"` + BeginYear int `json:"begin_year"` + EndYear int `json:"end_year"` + Rels map[string]string `json:"rels"` + } + + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("batch artist metadata unmarshal: %w", err) + } + + out := make(map[string]ArtistMetadata, len(raw)) + + for _, r := range raw { + mbid := r.ArtistMBID + if mbid == "" { + mbid = r.MBID + } + + meta := ArtistMetadata{ + MBID: mbid, + Name: r.Name, + Type: r.Type, + Country: r.Area, + BeginYear: r.BeginYear, + EndYear: r.EndYear, + } + + // Extract wikidata QID from rels map. + if wikidata, ok := r.Rels["wikidata"]; ok { + parts := strings.Split(wikidata, "/") + if len(parts) > 0 { + meta.WikidataQID = parts[len(parts)-1] + } + } + + out[mbid] = meta + } + + c.cacheJSON(cacheKey, out, cacheTTLEntity, "", "") + + return out, nil +} + // parsePopularity unmarshals a bulk popularity response, extracts -// the MBID→listenCount mapping, caches it, and returns it. +// the MBID→PopularityData mapping, caches it, and returns it. func (c *ListenBrainzClient) parsePopularity( cacheKey string, body []byte, extractMBID func(lbPopularityResult) string, -) (map[string]int, error) { +) (map[string]PopularityData, error) { var raw []lbPopularityResult if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("popularity unmarshal: %w", err) } - out := make(map[string]int, len(raw)) + out := make(map[string]PopularityData, len(raw)) for _, r := range raw { mbid := extractMBID(r) if mbid != "" && r.TotalListenCount != nil { - out[mbid] = *r.TotalListenCount + data := PopularityData{ListenCount: *r.TotalListenCount} + if r.TotalUserCount != nil { + data.ListenerCount = *r.TotalUserCount + } + + out[mbid] = data } } diff --git a/backend/explore/musicbrainz.go b/backend/explore/musicbrainz.go index ea86e73..25493c8 100644 --- a/backend/explore/musicbrainz.go +++ b/backend/explore/musicbrainz.go @@ -3,6 +3,7 @@ package explore import ( "context" "encoding/json" + "fmt" "log/slog" "time" "unicode" @@ -63,21 +64,22 @@ func (c *MusicBrainzClient) Close() error { // --------------------------------------------------------------------------- // SearchArtists queries MusicBrainz for artists matching the given -// query string. Results are cached for 1 day. +// query string. Returns results, the total match count from MB, +// and any error. Results are cached for 1 day. func (c *MusicBrainzClient) SearchArtists( ctx context.Context, query string, limit int, -) ([]MBArtist, error) { - cacheKey := "mb:search:artist:" + query +) ([]MBArtist, int, error) { + cacheKey := fmt.Sprintf("mb:search:artist:%s:%d", query, limit) if data, ok := c.cache.Get(cacheKey); ok { - var out []MBArtist - if err := json.Unmarshal(data, &out); err == nil { - return out, nil + var cached mbSearchCache[MBArtist] + if err := json.Unmarshal(data, &cached); err == nil { + return cached.Results, cached.TotalCount, nil } } if err := c.limiter.Wait(ctx); err != nil { - return nil, err + return nil, 0, err } c.logger.Info("musicbrainz search artists", @@ -90,32 +92,34 @@ func (c *MusicBrainzClient) SearchArtists( musicbrainzws2.Paginator{Limit: clampLimit(limit)}, ) if err != nil { - return nil, err + return nil, 0, err } out := convertArtists(result.Artists) - c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "") + c.cacheJSON(cacheKey, mbSearchCache[MBArtist]{ + Results: out, TotalCount: result.Count, + }, cacheTTLSearch, "", "") - return out, nil + return out, result.Count, nil } // SearchReleaseGroups queries MusicBrainz for release groups // matching the given query string. func (c *MusicBrainzClient) SearchReleaseGroups( ctx context.Context, query string, limit int, -) ([]MBReleaseGroup, error) { - cacheKey := "mb:search:release-group:" + query +) ([]MBReleaseGroup, int, error) { + cacheKey := fmt.Sprintf("mb:search:release-group:%s:%d", query, limit) if data, ok := c.cache.Get(cacheKey); ok { - var out []MBReleaseGroup - if err := json.Unmarshal(data, &out); err == nil { - return out, nil + var cached mbSearchCache[MBReleaseGroup] + if err := json.Unmarshal(data, &cached); err == nil { + return cached.Results, cached.TotalCount, nil } } if err := c.limiter.Wait(ctx); err != nil { - return nil, err + return nil, 0, err } c.logger.Info("musicbrainz search release groups", @@ -128,32 +132,34 @@ func (c *MusicBrainzClient) SearchReleaseGroups( musicbrainzws2.Paginator{Limit: clampLimit(limit)}, ) if err != nil { - return nil, err + return nil, 0, err } out := convertReleaseGroups(result.ReleaseGroups) - c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "") + c.cacheJSON(cacheKey, mbSearchCache[MBReleaseGroup]{ + Results: out, TotalCount: result.Count, + }, cacheTTLSearch, "", "") - return out, nil + return out, result.Count, nil } // SearchRecordings queries MusicBrainz for recordings matching the // given query string. func (c *MusicBrainzClient) SearchRecordings( ctx context.Context, query string, limit int, -) ([]MBRecording, error) { - cacheKey := "mb:search:recording:" + query +) ([]MBRecording, int, error) { + cacheKey := fmt.Sprintf("mb:search:recording:%s:%d", query, limit) if data, ok := c.cache.Get(cacheKey); ok { - var out []MBRecording - if err := json.Unmarshal(data, &out); err == nil { - return out, nil + var cached mbSearchCache[MBRecording] + if err := json.Unmarshal(data, &cached); err == nil { + return cached.Results, cached.TotalCount, nil } } if err := c.limiter.Wait(ctx); err != nil { - return nil, err + return nil, 0, err } c.logger.Info("musicbrainz search recordings", @@ -166,14 +172,22 @@ func (c *MusicBrainzClient) SearchRecordings( musicbrainzws2.Paginator{Limit: clampLimit(limit)}, ) if err != nil { - return nil, err + return nil, 0, err } out := convertRecordings(result.Recordings) - c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "") + c.cacheJSON(cacheKey, mbSearchCache[MBRecording]{ + Results: out, TotalCount: result.Count, + }, cacheTTLSearch, "", "") - return out, nil + return out, result.Count, nil +} + +// mbSearchCache wraps search results with the total count for caching. +type mbSearchCache[T any] struct { + Results []T `json:"results"` + TotalCount int `json:"totalCount"` } // --------------------------------------------------------------------------- @@ -181,6 +195,8 @@ func (c *MusicBrainzClient) SearchRecordings( // --------------------------------------------------------------------------- // LookupArtist fetches a single artist by MBID. Cached for 7 days. +// Uses inc=release-groups to pre-populate the browse cache so the +// subsequent BrowseReleaseGroups call is a free cache hit. func (c *MusicBrainzClient) LookupArtist( ctx context.Context, mbid string, ) (*MBArtist, error) { @@ -201,7 +217,7 @@ func (c *MusicBrainzClient) LookupArtist( a, err := c.mb.LookupArtist(ctx, mbtypes.MBID(mbid), - musicbrainzws2.IncludesFilter{}, + musicbrainzws2.IncludesFilter{Includes: []string{"release-groups"}}, ) if err != nil { return nil, err @@ -211,6 +227,16 @@ func (c *MusicBrainzClient) LookupArtist( c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "artist") + // Pre-populate the browse cache with the included release groups + // so BrowseReleaseGroups returns instantly from cache. + // The inc= response is limited to 25 items; only cache if we + // likely got the full discography (< 25 means no truncation). + if len(a.ReleaseGroups) > 0 && len(a.ReleaseGroups) < 25 { + browseKey := "mb:browse:release-groups:" + mbid + rgs := convertReleaseGroups(a.ReleaseGroups) + c.cacheJSON(browseKey, rgs, cacheTTLEntity, mbid, "artist") + } + return &out, nil } @@ -465,12 +491,29 @@ func convertRelease(r musicbrainzws2.Release) MBRelease { for _, m := range r.Media { for _, t := range m.Tracks { + // Use the recording MBID, not the track MBID. Tracks + // and recordings have distinct MBIDs in MusicBrainz: + // a track is the placement of a recording on a specific + // medium/release, while a recording is the underlying + // audio work. Library-tagged audio files store the + // recording MBID (MusicBrainz Track Id is a misnomer), + // so that's what the local recordings.mbid column + // contains — and that's what we need to match against + // for the library-status indicator to be accurate. + recordingMBID := string(t.Recording.ID) + if recordingMBID == "" { + // Fall back to the track MBID if the API response + // didn't include the recording relation (older + // browse endpoints). Better than empty. + recordingMBID = string(t.ID) + } + rel.Tracks = append(rel.Tracks, MBTrack{ Position: t.Position, DiscNumber: m.Position, Title: t.Title, Length: int(t.Length.Milliseconds()), - MBID: string(t.ID), + MBID: recordingMBID, }) } } diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index f768a19..23ef848 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -13,6 +13,9 @@ import ( "time" "yellowjacket/backend/database" + "yellowjacket/backend/events" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) // Index build parameters. @@ -30,16 +33,18 @@ const ( indexTopArtists = 1000 // indexMaxRGs is the ceiling for release groups per artist. - indexMaxRGs = 20 + // Top-popularity artists get their full discography. + indexMaxRGs = 50 // indexMinRGs is the floor for release groups per artist. - indexMinRGs = 5 + // Even the least popular indexed artist gets a couple of albums. + indexMinRGs = 2 // indexMaxRecs is the ceiling for recordings per artist. - indexMaxRecs = 100 + indexMaxRecs = 200 // indexMinRecs is the floor for recordings per artist. - indexMinRecs = 10 + indexMinRecs = 5 // indexMinPopularity is the minimum listen count for an entry // to be indexed. Cuts noise from long-tail entries. @@ -55,9 +60,10 @@ const ( // indexProgressInterval is how often to log progress. indexProgressInterval = 100 - // indexSimilarPerArtist is how many similar artists to consider - // per library artist for Tier 4 expansion. - indexSimilarPerArtist = 50 + // indexSimilarPerArtist is how many similar artists to store + // per library artist in similar_artist_map and to consider + // for Tier 4 discography expansion. + indexSimilarPerArtist = 20 // indexPopularityExponent controls how steeply the per-artist // budget scales with popularity. Lower = steeper curve. @@ -71,22 +77,69 @@ const ( // 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" + + // similarArtistsBatchSize is the number of seed MBIDs processed + // in one logging "batch" during Tier 4. The labs multi-seed + // POST form is broken, so we actually issue one GET per seed + // (concurrency bounded by indexerRate); batching here just + // keeps progress log output bounded. + similarArtistsBatchSize = 50 ) // 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"` - Popularity int `json:"popularity"` - ExtraJSON string `json:"extraJson,omitempty"` - Aliases string `json:"aliases,omitempty"` - InLibrary bool `json:"inLibrary"` - IsSimilar bool `json:"isSimilar"` + 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"` + + // 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 an artist row as having had its full + // discography (release groups + recordings) fetched by the + // indexer pipeline. Only set on artist entity_type entries. + // Used by indexedArtistMBIDs() to skip already-processed + // artists in tier 2/3. + 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"` + + // Schema version for staleness detection. + SchemaVersion int `json:"-"` } +// currentSchemaVersion is bumped when we add new fields that should +// trigger re-indexing of existing rows. The build logic checks each +// artist's rows against this version and re-fetches if stale. +const currentSchemaVersion = 1 + // lbSitewideArtist is the response shape from the LB sitewide // top-artists endpoint. type lbSitewideArtist struct { @@ -109,6 +162,7 @@ type SearchIndex struct { lb *ListenBrainzClient artistImg *ArtistImageProvider logger *slog.Logger + runtimeCtx context.Context // Wails runtime context for event emission cancel context.CancelFunc done chan struct{} @@ -116,6 +170,30 @@ type SearchIndex struct { mu sync.RWMutex ready bool maxListens int // highest artist listen count seen, for scaling + + // Build status tracking — read by GetIndexStatus for the UI. + buildStatus IndexStatus +} + +// 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"` +} + +// 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 @@ -134,6 +212,39 @@ func NewSearchIndex( } } +// 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. + si.refreshStatusCounts() + + // Start a background ticker that emits status every 3 seconds. + // This replaces frontend polling — the Wails binding dispatcher + // can be blocked by other calls, but EventsEmit bypasses it. + go func() { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + si.emitStatus() + } + } + }() +} + // IndexNewArtists indexes only library artists that are not yet in the // search index. This is the lightweight post-scan path — no tier // machinery, no freshness checks, no sitewide/similar artist logic. @@ -164,6 +275,10 @@ func (si *SearchIndex) IndexNewArtists(ctx context.Context) { si.mu.Unlock() close(si.done) + + // Mark ready if we indexed anything, so search works + // while the full tier build is pending. + si.MarkReadyIfPopulated() }() si.indexNewLibraryArtists(buildCtx) @@ -224,7 +339,7 @@ func (si *SearchIndex) indexNewLibraryArtists(ctx context.Context) { indexLimiter := NewRateLimiterN(indexerRate) indexLB := NewListenBrainzClient(indexLimiter, si.lb.cache, si.logger.WithGroup("indexer")) - si.indexArtistDiscographies(ctx, indexLB, newArtists, "new-artists") + si.indexArtistDiscographies(ctx, indexLB, newArtists, "new-artists", true) si.logger.Info("search index: new library artists indexed", "count", len(newArtists), @@ -281,6 +396,18 @@ func (si *SearchIndex) StopBuild() { } } +// 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() @@ -289,6 +416,137 @@ func (si *SearchIndex) IsReady() bool { 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 string + var count int + + if err := rows.Scan(&et, &count); err == nil { + switch et { + case "artist": + artists = count + case "recording": + recordings = count + case "release_group": + rgs = count + } + } + } + } + + var lastBuilt string + + metaRow, err := si.db.QueryContext( + "SELECT value FROM explore_index_meta WHERE key = 'tier5_built'", + ) + 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.mu.Lock() + + for i := range si.buildStatus.Tiers { + if si.buildStatus.Tiers[i].Name == name { + si.buildStatus.Tiers[i].State = state + si.buildStatus.Tiers[i].Total = total + si.buildStatus.Tiers[i].Completed = completed + si.mu.Unlock() + si.emitStatus() + + return + } + } + + si.buildStatus.Tiers = append(si.buildStatus.Tiers, TierStatus{ + Name: name, + State: state, + Total: total, + Completed: completed, + }) + + si.mu.Unlock() + si.emitStatus() +} + +// setTierError marks a tier as errored. +func (si *SearchIndex) setTierError(name, errMsg string) { + si.mu.Lock() + + for i := range si.buildStatus.Tiers { + if si.buildStatus.Tiers[i].Name == name { + si.buildStatus.Tiers[i].State = "error" + si.buildStatus.Tiers[i].Error = errMsg + si.mu.Unlock() + si.emitStatus() + + return + } + } + + si.mu.Unlock() +} + +// emitStatus pushes the current index status to the frontend via Wails event. +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.logger.Info("emitting index status event", + "building", status.Building, + "tiers", len(status.Tiers), + "ready", status.Ready, + ) + + runtime.EventsEmit(si.runtimeCtx, events.IndexStatusChanged, 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 { @@ -316,10 +574,13 @@ func (si *SearchIndex) GetPopularity(mbid string) int { return 0 } -// PopularityBatchResult contains popularity and library status. +// PopularityBatchResult contains popularity, listener count, library +// status, and similarity scores for a batch of MBIDs. type PopularityBatchResult struct { - Popularity map[string]int - InLibrary map[string]bool + 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 @@ -337,7 +598,7 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult args[i] = m } - query := "SELECT mbid, popularity, in_library FROM explore_index WHERE mbid IN (" + + query := "SELECT mbid, popularity, listener_count, in_library FROM explore_index WHERE mbid IN (" + strings.Join(placeholders, ",") + ")" rows, err := si.db.QueryContext(query, args...) @@ -348,27 +609,37 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult defer func() { _ = rows.Close() }() result := &PopularityBatchResult{ - Popularity: make(map[string]int, len(mbids)), - InLibrary: make(map[string]bool), + 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 mbid string var pop int + var listeners int var inLib int - if err := rows.Scan(&mbid, &pop, &inLib); err == nil { + if err := rows.Scan(&mbid, &pop, &listeners, &inLib); err == nil { 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 } @@ -392,6 +663,226 @@ func (si *SearchIndex) IsInLibrary(mbid string) bool { 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 = 'artist' LIMIT 1`, + mbid, + ) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + return nil + } + + r := SearchIndexResult{ + EntityType: "artist", + MBID: mbid, + } + + if err := rows.Scan( + &r.Title, &r.ArtistName, &r.ArtistMBID, &r.Popularity, &r.ListenerCount, + &r.ArtistType, &r.Country, &r.Disambiguation, &r.SortName, &r.Aliases, + &r.InLibrary, &r.IsSimilar, &r.LocalArtistID, + ); err != nil { + return nil + } + + 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] = m + } + + query := `SELECT caa_release_mbid, mbid + FROM explore_index + WHERE entity_type = 'release_group' + AND caa_release_mbid != '' + 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 string + if err := rows.Scan(&caaMBID, &rgMBID); err == nil { + out[caaMBID] = 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, + in_library, COALESCE(local_release_group_id, 0) + FROM explore_index + WHERE mbid = ? AND entity_type = 'release_group' LIMIT 1`, + mbid, + ) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + return nil + } + + r := SearchIndexResult{ + EntityType: "release_group", + MBID: mbid, + } + + if err := rows.Scan( + &r.Title, &r.ArtistName, &r.ArtistMBID, &r.Popularity, &r.ListenerCount, + &r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, + &r.InLibrary, &r.LocalReleaseGroupID, + ); err != nil { + return nil + } + + return &r +} + +// 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 = 'recording' + ORDER BY popularity DESC + LIMIT ?`, + artistMBID, limit, + ) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + var results []SearchIndexResult + + for rows.Next() { + var r SearchIndexResult + if err := rows.Scan( + &r.MBID, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount, + &r.Duration, &r.CAAReleaseMBID, &r.ReleaseName, + &r.InLibrary, &r.LocalRecordingID, + ); err == nil { + r.EntityType = "recording" + 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 = 'release_group' + ORDER BY popularity DESC + LIMIT ?`, + artistMBID, limit, + ) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + var results []SearchIndexResult + + for rows.Next() { + var r SearchIndexResult + if err := rows.Scan( + &r.MBID, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount, + &r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, + &r.InLibrary, &r.LocalReleaseGroupID, + ); err == nil { + r.EntityType = "release_group" + r.ArtistMBID = artistMBID + results = append(results, r) + } + } + + return results +} + // AddFromCache inserts entries from a cached discography browse // into the search index (Tier 5: organic growth). Called when a // user views an artist page and the discography is fetched. @@ -413,20 +904,20 @@ func (si *SearchIndex) AddFromCache(artistName, artistMBID string, rgs []MBRelea }) for _, rg := range rgs { - extra, _ := json.Marshal(map[string]string{"type": rg.PrimaryType}) - entries = append(entries, SearchIndexResult{ - EntityType: "release_group", - MBID: rg.MBID, - Title: rg.Title, - ArtistName: artistName, - ArtistMBID: artistMBID, - Popularity: 0, - ExtraJSON: string(extra), + 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.writeBatch(entries) + si.upsertBatch(entries) si.logger.Debug("search index: organic add", "artist", artistName, @@ -436,11 +927,107 @@ func (si *SearchIndex) AddFromCache(artistName, artistMBID string, rgs []MBRelea // Search queries the local FTS5 index and returns matches sorted // by popularity descending. -func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult { +// 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 + } + + rows, err := si.db.QueryContext(` + SELECT entity_type, mbid, title, artist_name, artist_mbid, + popularity, listener_count, duration, primary_type, + secondary_types, release_date, caa_release_mbid, + release_name, artist_type, country, disambiguation, + sort_name, in_library, is_similar, + COALESCE(local_artist_id, 0), + COALESCE(local_release_group_id, 0), + COALESCE(local_recording_id, 0) + FROM explore_index + WHERE (LOWER(title) = ? OR LOWER(artist_name) = ?) + AND popularity > 0 + ORDER BY entity_type, popularity DESC + `, q, q) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + // Group by entity type and cap at perCategory each, ordered by + // popularity desc because the SQL `ORDER BY entity_type, popularity DESC` + // gives us entity-type buckets already sorted within each. + buckets := map[string][]SearchIndexResult{ + "artist": nil, + "release_group": nil, + "recording": nil, + } + + for rows.Next() { + var r SearchIndexResult + + if err := rows.Scan( + &r.EntityType, &r.MBID, &r.Title, &r.ArtistName, &r.ArtistMBID, + &r.Popularity, &r.ListenerCount, &r.Duration, &r.PrimaryType, + &r.SecondaryTypes, &r.ReleaseDate, &r.CAAReleaseMBID, + &r.ReleaseName, &r.ArtistType, &r.Country, &r.Disambiguation, + &r.SortName, &r.InLibrary, &r.IsSimilar, + &r.LocalArtistID, &r.LocalReleaseGroupID, &r.LocalRecordingID, + ); 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. + qLower := strings.ToLower(q) + titleMatch := strings.ToLower(r.Title) == qLower + artistMatch := strings.ToLower(r.ArtistName) == qLower + + if r.EntityType == "artist" && !titleMatch { + continue + } + + if r.EntityType != "artist" && !titleMatch && !artistMatch { + continue + } + + 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 +} + +func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult { if !si.IsReady() { + return nil + } + if limit <= 0 { limit = 20 } @@ -452,8 +1039,14 @@ func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult { rows, err := si.db.QueryContext(` SELECT i.entity_type, i.mbid, i.title, i.artist_name, - i.artist_mbid, i.popularity, i.extra_json, - i.in_library, i.is_similar + i.artist_mbid, i.popularity, i.listener_count, + i.duration, i.primary_type, i.secondary_types, i.release_date, + i.caa_release_mbid, i.release_name, + i.artist_type, i.country, i.disambiguation, i.sort_name, + i.in_library, i.is_similar, + COALESCE(i.local_artist_id, 0), + COALESCE(i.local_release_group_id, 0), + COALESCE(i.local_recording_id, 0) FROM explore_index i JOIN explore_index_fts f ON f.rowid = i.id WHERE explore_index_fts MATCH ? @@ -480,22 +1073,20 @@ func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult { for rows.Next() { var r SearchIndexResult - var extraJSON *string - if err := rows.Scan( &r.EntityType, &r.MBID, &r.Title, &r.ArtistName, - &r.ArtistMBID, &r.Popularity, &extraJSON, + &r.ArtistMBID, &r.Popularity, &r.ListenerCount, + &r.Duration, &r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, + &r.CAAReleaseMBID, &r.ReleaseName, + &r.ArtistType, &r.Country, &r.Disambiguation, &r.SortName, &r.InLibrary, &r.IsSimilar, + &r.LocalArtistID, &r.LocalReleaseGroupID, &r.LocalRecordingID, ); err != nil { si.logger.Warn("search index scan error", "error", err) continue } - if extraJSON != nil { - r.ExtraJSON = *extraJSON - } - results = append(results, r) } @@ -563,6 +1154,20 @@ func (si *SearchIndex) build(ctx context.Context) { si.logger.Info("search index build starting") + // Initialize tier status for the UI. + si.mu.Lock() + si.buildStatus = IndexStatus{ + Building: true, + Tiers: []TierStatus{ + {Name: "Sitewide Top Lists", State: "pending"}, + {Name: "Sitewide Discographies", State: "pending"}, + {Name: "Library Artists", State: "pending"}, + {Name: "Similar Artists", State: "pending"}, + {Name: "Popularity Backfill", State: "pending"}, + }, + } + si.mu.Unlock() + // Mark ready from existing rows so search works during the build. si.MarkReadyIfPopulated() @@ -576,9 +1181,11 @@ func (si *SearchIndex) build(ctx context.Context) { if tier1Fresh { si.logger.Info("search index: Tier 1 fresh, loading cached artists") + si.setTierStatus("Sitewide Top Lists", "skipped", 0, 0) sitewideArtists = si.loadCachedSitewideArtists() } else { + si.setTierStatus("Sitewide Top Lists", "running", 12, 0) sitewideArtists = si.buildTier1Sitewide(ctx, indexLB) if ctx.Err() != nil { @@ -586,12 +1193,14 @@ func (si *SearchIndex) build(ctx context.Context) { } si.setMeta("tier1_built", time.Now().UTC().Format(time.RFC3339)) + si.setTierStatus("Sitewide Top Lists", "complete", 12, 12) } si.mu.Lock() si.ready = true si.mu.Unlock() + si.refreshStatusCounts() si.logger.Info("search index: Tier 1 complete (sitewide instant)") // Tiers 2-4: discographies — refresh monthly, incremental. @@ -602,6 +1211,36 @@ func (si *SearchIndex) build(ctx context.Context) { tier3Fresh := si.isMetaFresh("tier3_built", indexTier2Interval) tier4Fresh := si.isMetaFresh("tier4_built", indexTier2Interval) + // Repair pass: runs unconditionally (outside the tier-fresh + // gate) so gaps from previous incomplete runs are healed even + // when the tier timestamps claim the build is fresh. Any + // artist row with discog_fetched=0 — including those created + // by AddFromCache during a frontend visit, or left over from + // a crash mid-build — gets its full discography pulled here. + { + indexedForRepair := si.indexedArtistMBIDs() + if unindexed := si.unindexedArtistEntries(indexedForRepair); len(unindexed) > 0 { + si.logger.Info("search index: repair pass starting", + "unindexedArtists", len(unindexed), + ) + + si.setTierStatus("Repair Discographies", "running", len(unindexed), 0) + si.indexArtistDiscographies(ctx, indexLB, unindexed, "Repair", false) + + if ctx.Err() != nil { + return + } + + si.setTierStatus("Repair Discographies", "complete", len(unindexed), len(unindexed)) + si.refreshStatusCounts() + si.logger.Info("search index: repair pass complete", + "artists", len(unindexed), + ) + } else { + si.setTierStatus("Repair Discographies", "skipped", 0, 0) + } + } + if tier2Fresh && tier3Fresh && tier4Fresh { si.logger.Info("search index: discographies fresh, skipping Tiers 2-4") } else { @@ -612,6 +1251,7 @@ func (si *SearchIndex) build(ctx context.Context) { // Tier 2: sitewide artists' discographies (incremental). if tier2Fresh { si.logger.Info("search index: Tier 2 fresh, skipping") + si.setTierStatus("Sitewide Discographies", "skipped", 0, 0) } else { newSitewide := filterUnindexed(sitewideArtists, indexed) @@ -621,20 +1261,25 @@ func (si *SearchIndex) build(ctx context.Context) { "new", len(newSitewide), ) - si.indexArtistDiscographies(ctx, indexLB, newSitewide, "Tier 2") + si.setTierStatus("Sitewide Discographies", "running", len(newSitewide), 0) + si.indexArtistDiscographies(ctx, indexLB, newSitewide, "Tier 2", false) if ctx.Err() != nil { return } si.setMeta("tier2_built", time.Now().UTC().Format(time.RFC3339)) + si.setTierStatus("Sitewide Discographies", "complete", len(newSitewide), len(newSitewide)) + si.refreshStatusCounts() si.logger.Info("search index: Tier 2 complete (sitewide discographies)") } // Tier 3: library artists' discographies (incremental). if tier3Fresh { si.logger.Info("search index: Tier 3 fresh, skipping") + si.setTierStatus("Library Artists", "skipped", 0, 0) } else { + si.setTierStatus("Library Artists", "running", 0, 0) indexed = si.indexedArtistMBIDs() libraryMBIDs = si.buildTier3Library(ctx, indexLB, sitewideArtists, indexed) @@ -643,12 +1288,14 @@ func (si *SearchIndex) build(ctx context.Context) { } si.setMeta("tier3_built", time.Now().UTC().Format(time.RFC3339)) + si.setTierStatus("Library Artists", "complete", len(libraryMBIDs), len(libraryMBIDs)) si.logger.Info("search index: Tier 3 complete (library discographies)") } // Tier 4: similar artists (incremental). if tier4Fresh { si.logger.Info("search index: Tier 4 fresh, skipping") + si.setTierStatus("Similar Artists", "skipped", 0, 0) } else { if libraryMBIDs == nil { // Tier 3 was skipped, load library MBIDs for Tier 4. @@ -656,6 +1303,7 @@ func (si *SearchIndex) build(ctx context.Context) { } indexed = si.indexedArtistMBIDs() + si.setTierStatus("Similar Artists", "running", len(libraryMBIDs), 0) si.buildTier4Similar(ctx, indexLB, libraryMBIDs, indexed) if ctx.Err() != nil { @@ -663,10 +1311,37 @@ func (si *SearchIndex) build(ctx context.Context) { } si.setMeta("tier4_built", time.Now().UTC().Format(time.RFC3339)) + si.setTierStatus("Similar Artists", "complete", len(libraryMBIDs), len(libraryMBIDs)) + si.refreshStatusCounts() si.logger.Info("search index: Tier 4 complete (similar artists)") } } + // Tier 5: backfill popularity for entities with missing data. + tier5Fresh := si.isMetaFresh("tier5_built", indexTier1Interval) + if tier5Fresh { + si.setTierStatus("Popularity Backfill", "skipped", 0, 0) + } else { + si.setTierStatus("Popularity Backfill", "running", 0, 0) + si.buildTier5Popularity(ctx, indexLB) + if ctx.Err() != nil { + return + } + + si.setMeta("tier5_built", time.Now().UTC().Format(time.RFC3339)) + si.setTierStatus("Popularity Backfill", "complete", 0, 0) + si.logger.Info("search index: Tier 5 complete (popularity backfill)") + } + + si.mu.Lock() + si.buildStatus.Building = false + si.mu.Unlock() + + // Populate local library cross-reference columns so every + // read path has O(1) access to "do I own this?" + si.PopulateLocalCrossReferences() + + si.refreshStatusCounts() si.logger.Info("search index build complete", "elapsed", time.Since(start).Round(time.Second)) } @@ -826,7 +1501,8 @@ func (si *SearchIndex) fetchSitewideRecordings( Title: r.TrackName, ArtistName: r.ArtistName, ArtistMBID: artistMBID, - Popularity: r.ListenCount, + // Popularity intentionally 0 — backfilled by Tier 5 with the + // uncapped total_listen_count from the popularity API. }) } @@ -888,7 +1564,7 @@ func (si *SearchIndex) fetchSitewideReleaseGroups( Title: r.ReleaseGroupName, ArtistName: r.ArtistName, ArtistMBID: artistMBID, - Popularity: r.ListenCount, + // Popularity intentionally 0 — backfilled by Tier 5. }) } @@ -1004,7 +1680,7 @@ func (si *SearchIndex) buildTier3Library( } if len(matched) > 0 { - si.indexArtistDiscographies(ctx, lb, matched, "Tier 3") + si.indexArtistDiscographies(ctx, lb, matched, "Tier 3", true) // Mark all Tier 3 entries as in_library. si.markInLibrary(matched) @@ -1032,38 +1708,30 @@ func (si *SearchIndex) buildTier4Similar( return } - // Fetch similar artists for each library artist. + // Fetch similar artists in batches of seeds, fanned out + // concurrently. The labs multi-seed POST form is broken and + // returns mis-grouped results, so fetchSimilarArtistsBatch + // actually makes one GET per seed (see its comment). Batches + // keep the log output bounded. newArtistMap := make(map[string]lbSitewideArtist) - var mu sync.Mutex - - sem := make(chan struct{}, indexerRate) - - var wg sync.WaitGroup - - var completed atomic.Int32 - - for _, mbid := range libraryMBIDs { + for i := 0; i < len(libraryMBIDs); i += similarArtistsBatchSize { if ctx.Err() != nil { break } - sem <- struct{}{} + end := i + similarArtistsBatchSize + if end > len(libraryMBIDs) { + end = len(libraryMBIDs) + } - wg.Add(1) + batch := libraryMBIDs[i:end] + grouped := si.fetchSimilarArtistsBatch(ctx, lb, batch) - go func(artistMBID string) { - defer func() { - <-sem - wg.Done() - }() - - similar := si.fetchSimilarArtists(ctx, artistMBID) - - // Persist the similar artist relationships. - si.storeSimilarArtists(artistMBID, similar) - - mu.Lock() + // Persist similarity relationships per seed. + for _, seedMBID := range batch { + similar := grouped[seedMBID] + si.storeSimilarArtists(seedMBID, similar) for _, s := range similar { if !indexed[s.ArtistMBID] { @@ -1075,21 +1743,15 @@ func (si *SearchIndex) buildTier4Similar( } } } + } - mu.Unlock() - - n := completed.Add(1) - if int(n)%indexProgressInterval == 0 { - si.logger.Info("search index: Tier 4 similar progress", - "completed", n, - "total", len(libraryMBIDs), - ) - } - }(mbid) + si.logger.Info("search index: Tier 4 similar batch complete", + "batch", (i/similarArtistsBatchSize)+1, + "totalBatches", (len(libraryMBIDs)+similarArtistsBatchSize-1)/similarArtistsBatchSize, + "processed", end, + ) } - wg.Wait() - if len(newArtistMap) == 0 { return } @@ -1103,53 +1765,95 @@ func (si *SearchIndex) buildTier4Similar( "newArtists", len(newArtists), ) - si.indexArtistDiscographies(ctx, lb, newArtists, "Tier 4") + // Index artist rows only (no discography) — similar artists + // are mostly obscure and their per-track data rarely surfaces + // in searches. Discographies are fetched on-demand when the + // user drills into an artist detail view. This saves ~2 API + // calls per artist (~10K total for Tier 4). + si.indexArtistDiscographies(ctx, lb, newArtists, "Tier 4", false) // Mark all Tier 4 entries as similar. si.markSimilar(newArtists) } type lbSimilarArtistWire struct { - ArtistMBID string `json:"artist_mbid"` - Name string `json:"name"` - Score int `json:"score"` + 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 } -func (si *SearchIndex) fetchSimilarArtists( - ctx context.Context, artistMBID string, -) []lbSimilarArtistWire { - url := fmt.Sprintf( - "%s/similar-artists/json?artist_mbids=%s&algorithm=%s", - labsBaseURL, artistMBID, labsSimilarAlgorithm, +// fetchSimilarArtistsBatch queries the labs similar-artists endpoint +// for multiple seed MBIDs. Despite the name, this actually fans +// out one request per seed: the labs API's multi-seed mode is +// broken (results for different seeds get mis-labeled, and some +// seeds return zero), so batching with multiple artist_mbids is +// not viable. Concurrency is bounded by indexerRate to respect +// the labs rate limit; each call goes through the provided LB +// client's rate limiter and cache. +func (si *SearchIndex) fetchSimilarArtistsBatch( + ctx context.Context, lb *ListenBrainzClient, seedMBIDs []string, +) map[string][]lbSimilarArtistWire { + if len(seedMBIDs) == 0 { + return nil + } + + var ( + mu sync.Mutex + grouped = make(map[string][]lbSimilarArtistWire, len(seedMBIDs)) + wg sync.WaitGroup ) - req, err := newLBRequest(ctx, url) - if err != nil { - return nil + sem := make(chan struct{}, indexerRate) + + for _, seedMBID := range seedMBIDs { + if ctx.Err() != nil { + break + } + + sem <- struct{}{} + + wg.Add(1) + + go func(seed string) { + defer func() { + <-sem + wg.Done() + }() + + // Use the LB client's per-seed GET form — goes through + // the shared rate limiter and cache. The multi-seed + // POST form is not viable (see function comment). + similar, err := lb.SimilarArtists(ctx, seed) + if err != nil || len(similar) == 0 { + return + } + + // Convert to the internal wire type used by the caller + // and trim to indexSimilarPerArtist. + if len(similar) > indexSimilarPerArtist { + similar = similar[:indexSimilarPerArtist] + } + + results := make([]lbSimilarArtistWire, len(similar)) + for i, s := range similar { + results[i] = lbSimilarArtistWire{ + ArtistMBID: s.ArtistMBID, + Name: s.Name, + Score: int(s.Score), + ReferenceMBID: seed, + } + } + + mu.Lock() + grouped[seed] = results + mu.Unlock() + }(seedMBID) } - resp, err := si.lb.http.Do(req) - if err != nil { - return nil - } + wg.Wait() - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil - } - - var results []lbSimilarArtistWire - if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { - return nil - } - - limit := indexSimilarPerArtist - if limit > len(results) { - limit = len(results) - } - - return results[:limit] + return grouped } // --------------------------------------------------------------------------- @@ -1163,11 +1867,19 @@ func (si *SearchIndex) indexArtistDiscographies( lb *ListenBrainzClient, artists []lbSitewideArtist, tier string, + forceMax bool, ) { if len(artists) == 0 { return } + // Prefetch artist metadata in batches of 1000 — one GET per batch + // instead of one per artist. Populates type, country, and writes + // artist rows with these fields up-front. This runs to completion + // before the discography loop so indexOneArtist can read from + // the batch results via the metadata cache. + si.prefetchArtistMetadata(ctx, lb, artists) + sem := make(chan struct{}, indexerRate) var wg sync.WaitGroup @@ -1189,9 +1901,21 @@ func (si *SearchIndex) indexArtistDiscographies( wg.Done() }() - si.indexOneArtist(ctx, lb, artist) + si.indexOneArtist(ctx, lb, artist, forceMax) n := completed.Add(1) + + // Update tier status for the UI. + tierName := tier // "Tier 2" or "Tier 3" + switch tier { + case "Tier 2": + tierName = "Sitewide Discographies" + case "Tier 3": + tierName = "Library Artists" + } + + si.setTierStatus(tierName, "running", len(artists), int(n)) + if int(n)%indexProgressInterval == 0 { si.logger.Info("search index progress", "tier", tier, @@ -1211,16 +1935,120 @@ func (si *SearchIndex) indexArtistDiscographies( ) } +// prefetchArtistMetadata batch-fetches artist metadata from LB's +// /1/metadata/artist/ endpoint and writes artist rows up-front. +// This populates type and country for all artists in a single +// GET per 1000-artist chunk, rather than requiring per-artist +// MB calls. Aliases, disambiguation, and sort_name still come +// from the per-artist MB fetch during image resolution — unless +// we can synthesize a satisfactory cached response. +// +// The prefetch also pre-populates the mb:artist-rels cache with +// a synthesized envelope derived from LB data, so the per-artist +// MB call is skipped entirely for artists where we have LB data. +// Aliases/disambiguation won't be available, but type/country/ +// name and wikidata QID (for image resolution) will be. +func (si *SearchIndex) prefetchArtistMetadata( + ctx context.Context, + lb *ListenBrainzClient, + artists []lbSitewideArtist, +) { + const batchSize = 1000 + + var ( + processed atomic.Int32 + batchWG sync.WaitGroup + ) + + // Build a lookup from mbid to artist name. + nameByMBID := make(map[string]string, len(artists)) + for _, a := range artists { + nameByMBID[a.ArtistMBID] = a.ArtistName + } + + mbids := make([]string, 0, len(artists)) + for _, a := range artists { + if a.ArtistMBID != "" { + mbids = append(mbids, a.ArtistMBID) + } + } + + for i := 0; i < len(mbids); i += batchSize { + if ctx.Err() != nil { + return + } + + end := i + batchSize + if end > len(mbids) { + end = len(mbids) + } + + batch := mbids[i:end] + batchWG.Add(1) + + go func(chunk []string) { + defer batchWG.Done() + + meta, err := lb.BatchArtistMetadata(ctx, chunk) + if err != nil || len(meta) == 0 { + return + } + + // Upsert artist rows with the LB-sourced fields. + entries := make([]SearchIndexResult, 0, len(meta)) + + for mbid, m := range meta { + name := nameByMBID[mbid] + if name == "" { + name = m.Name + } + + entries = append(entries, SearchIndexResult{ + EntityType: "artist", + MBID: mbid, + Title: name, + ArtistName: name, + ArtistMBID: mbid, + ArtistType: m.Type, + Country: m.Country, + }) + + // Synthesize an MB artist-rels cache entry so + // fetchMBRels skips the per-artist network call. + // Contains only the wikidata URL (for image + // resolution) and the type/country/name that + // GetArtistDetails reads. Aliases and + // disambiguation are empty — those come from + // an on-demand MB lookup later if needed. + if si.artistImg != nil { + si.artistImg.PreloadArtistRels(mbid, m) + } + } + + si.upsertBatch(entries) + processed.Add(int32(len(entries))) + }(batch) + } + + batchWG.Wait() + + si.logger.Info("search index: prefetched artist metadata", + "artists", len(mbids), + "processed", processed.Load(), + ) +} + func (si *SearchIndex) indexOneArtist( ctx context.Context, lb *ListenBrainzClient, artist lbSitewideArtist, + forceMax bool, ) { if ctx.Err() != nil { return } - rgLimit, recLimit := si.scaledLimits(artist.ListenCount) + rgLimit, recLimit := si.scaledLimits(artist.ListenCount, forceMax) // Run LB discography fetches and MB artist image resolution // concurrently — they use different rate limiters so they @@ -1256,19 +2084,33 @@ func (si *SearchIndex) indexOneArtist( // Write the artist entry into the index so indexedArtistMBIDs() // recognises this artist as processed on subsequent builds. - // Also stores aliases from the now-cached MB rels (populated - // by the image resolution above) for FTS search. + // Only mark DiscogFetched=true if at least one of the discography + // fetches actually returned data — a transient API failure should + // allow a retry on the next build, not permanently claim the + // artist as indexed. Also stores aliases and detail fields from + // the now-cached MB rels (populated by the image resolution above) + // for FTS search. if si.artistImg != nil { - aliases := si.artistImg.GetAliases(artist.ArtistMBID) - si.writeBatch([]SearchIndexResult{{ - EntityType: "artist", - MBID: artist.ArtistMBID, - Title: artist.ArtistName, - ArtistName: artist.ArtistName, - ArtistMBID: artist.ArtistMBID, - Popularity: artist.ListenCount, - Aliases: aliases, - }}) + gotData := len(rgs) > 0 || len(recs) > 0 + 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. @@ -1282,7 +2124,7 @@ func (si *SearchIndex) indexOneArtist( end = len(all) } - si.writeBatch(all[i:end]) + si.upsertBatch(all[i:end]) } } @@ -1308,13 +2150,13 @@ func (si *SearchIndex) fetchTopReleaseGroups( } var raw []struct { - ReleaseGroupMBID string `json:"release_group_mbid"` - TotalListenCount int `json:"total_listen_count"` - CAAId *int64 `json:"caa_id"` - CAAReleaseGroupMBID string `json:"caa_release_mbid"` - ReleaseGroup struct { - Name string `json:"name"` - Type string `json:"type"` + 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 { @@ -1348,25 +2190,16 @@ func (si *SearchIndex) fetchTopReleaseGroups( artistMBID = r.Artist.Artists[0].ArtistMBID } - extraMap := map[string]any{"type": r.ReleaseGroup.Type} - if r.CAAId != nil { - extraMap["caaId"] = *r.CAAId - } - - if r.CAAReleaseGroupMBID != "" { - extraMap["caaReleaseMbid"] = r.CAAReleaseGroupMBID - } - - extra, _ := json.Marshal(extraMap) - results = append(results, SearchIndexResult{ - EntityType: "release_group", - MBID: r.ReleaseGroupMBID, - Title: r.ReleaseGroup.Name, - ArtistName: artistName, - ArtistMBID: artistMBID, - Popularity: r.TotalListenCount, - ExtraJSON: string(extra), + 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, }) } @@ -1407,22 +2240,173 @@ func (si *SearchIndex) fetchTopRecordings( } results = append(results, SearchIndexResult{ - EntityType: "recording", - MBID: r.RecordingMBID, - Title: r.RecordingName, - ArtistName: r.ArtistName, - ArtistMBID: artist.ArtistMBID, - Popularity: r.TotalListenCount, + 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 } +// --------------------------------------------------------------------------- +// Tier 5: popularity backfill +// --------------------------------------------------------------------------- + +// popularityBatchSize is the number of MBIDs per LB popularity request. +// LB accepts up to 1000 per POST call. +const popularityBatchSize = 1000 + +// buildTier5Popularity batch-queries LB popularity for every entity in the +// index that has popularity = 0, then writes results back via BackfillPopularity. +func (si *SearchIndex) buildTier5Popularity(ctx context.Context, lb *ListenBrainzClient) { + type entityKind struct { + entityType string + fetch func(context.Context, []string) (map[string]PopularityData, error) + } + + kinds := []entityKind{ + {"artist", lb.ArtistPopularity}, + {"release_group", lb.ReleaseGroupPopularity}, + {"recording", lb.RecordingPopularity}, + } + + for _, kind := range kinds { + if ctx.Err() != nil { + return + } + + mbids := si.mbidsWithoutPopularity(kind.entityType) + if len(mbids) == 0 { + si.logger.Info("search index: Tier 5 skipped (no missing popularity)", + "entityType", kind.entityType) + + continue + } + + batches := chunkStrings(mbids, popularityBatchSize) + + si.logger.Info("search index: Tier 5 starting", + "entityType", kind.entityType, + "entities", len(mbids), + "batches", len(batches), + ) + + var filled int + + sem := make(chan struct{}, indexerRate) + + for i, batch := range batches { + if ctx.Err() != nil { + return + } + + sem <- struct{}{} + + pops, err := kind.fetch(ctx, batch) + + <-sem + + if err != nil { + si.logger.Warn("search index: Tier 5 batch failed", + "entityType", kind.entityType, + "batch", i+1, + "error", err, + ) + + continue + } + + si.BackfillPopularity(pops) + filled += len(pops) + + if (i+1)%indexProgressInterval == 0 { + si.logger.Info("search index: Tier 5 progress", + "entityType", kind.entityType, + "batches", fmt.Sprintf("%d/%d", i+1, len(batches)), + "filled", filled, + ) + } + } + + si.logger.Info("search index: Tier 5 entity type done", + "entityType", kind.entityType, + "filled", filled, + "batches", len(batches), + ) + } +} + +// mbidsWithoutPopularity returns all MBIDs in the index for the given +// entity type that have popularity = 0. +func (si *SearchIndex) mbidsWithoutPopularity(entityType string) []string { + rows, err := si.db.QueryContext( + "SELECT mbid FROM explore_index WHERE entity_type = ? AND popularity = 0", + entityType, + ) + if err != nil { + si.logger.Warn("search index: failed to query unpopulated MBIDs", + "entityType", entityType, "error", err) + + return nil + } + + defer func() { _ = rows.Close() }() + + var mbids []string + + for rows.Next() { + var m string + if err := rows.Scan(&m); err == nil { + mbids = append(mbids, m) + } + } + + return mbids +} + +// chunkStrings splits a slice into chunks of at most size n. +func chunkStrings(s []string, n int) [][]string { + var chunks [][]string + + for i := 0; i < len(s); i += n { + end := i + n + if end > len(s) { + end = len(s) + } + + chunks = append(chunks, s[i:end]) + } + + return chunks +} + // --------------------------------------------------------------------------- // 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. + +// upsertArtists is a convenience wrapper for Tier 1 sitewide artists. +// Writes them as artist rows with popularity=0 — the actual popularity +// (uncapped total_listen_count) is filled in by Tier 5 via the +// POST popularity API. The sitewide listen_count is a capped +// different metric we don't want to mix in. func (si *SearchIndex) upsertArtists(artists []lbSitewideArtist) { batch := make([]SearchIndexResult, 0, indexBatchSize) @@ -1433,20 +2417,22 @@ func (si *SearchIndex) upsertArtists(artists []lbSitewideArtist) { Title: a.ArtistName, ArtistName: a.ArtistName, ArtistMBID: a.ArtistMBID, - Popularity: a.ListenCount, + // Popularity intentionally 0 — backfilled by Tier 5. }) if len(batch) >= indexBatchSize { - si.writeBatch(batch) + si.upsertBatch(batch) batch = batch[:0] } } if len(batch) > 0 { - si.writeBatch(batch) + si.upsertBatch(batch) } } +// upsertSearchResults chunks large batches into transactions of +// indexBatchSize and flushes each via upsertBatch. func (si *SearchIndex) upsertSearchResults(results []SearchIndexResult) { for i := 0; i < len(results); i += indexBatchSize { end := i + indexBatchSize @@ -1454,11 +2440,15 @@ func (si *SearchIndex) upsertSearchResults(results []SearchIndexResult) { end = len(results) } - si.writeBatch(results[i:end]) + si.upsertBatch(results[i:end]) } } -func (si *SearchIndex) writeBatch(entries []SearchIndexResult) { +// 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 } @@ -1471,6 +2461,10 @@ func (si *SearchIndex) writeBatch(entries []SearchIndexResult) { } for _, e := range entries { + if e.MBID == "" { + continue // skip entries without MBIDs — can't be looked up + } + inLib := 0 if e.InLibrary { inLib = 1 @@ -1481,15 +2475,85 @@ func (si *SearchIndex) writeBatch(entries []SearchIndexResult) { isSim = 1 } + discogFetched := 0 + if e.DiscogFetched { + discogFetched = 1 + } + if _, err := tx.Exec(` - INSERT OR REPLACE INTO explore_index - (entity_type, mbid, title, artist_name, artist_mbid, - popularity, extra_json, aliases, in_library, is_similar) - VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?) - `, e.EntityType, e.MBID, e.Title, e.ArtistName, e.ArtistMBID, - e.Popularity, e.ExtraJSON, e.Aliases, inLib, isSim, + 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, + artist_type, country, disambiguation, sort_name, + in_library, is_similar, + local_artist_id, local_release_group_id, local_recording_id, + discog_fetched, + schema_version + ) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, + ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, + ?, ?, + NULLIF(?, 0), NULLIF(?, 0), NULLIF(?, 0), + ?, + ? + ) + ON CONFLICT(mbid) DO UPDATE SET + -- Title and artist info: don't clobber a good value with + -- an empty string or with the MBID itself (which can sneak + -- in via fallback paths in AddFromCache). + title = CASE + WHEN excluded.title != '' AND excluded.title != excluded.mbid THEN excluded.title + ELSE title + END, + artist_name = CASE + WHEN excluded.artist_name != '' AND excluded.artist_name != excluded.artist_mbid THEN excluded.artist_name + ELSE artist_name + END, + artist_mbid = CASE WHEN excluded.artist_mbid != '' 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 != '' 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, + 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), + + schema_version = MAX(schema_version, excluded.schema_version) + `, + e.EntityType, e.MBID, e.Title, e.ArtistName, e.ArtistMBID, e.Aliases, + e.Popularity, e.ListenerCount, + e.Duration, e.CAAReleaseMBID, e.ReleaseName, + e.PrimaryType, e.SecondaryTypes, e.ReleaseDate, + e.ArtistType, e.Country, e.Disambiguation, e.SortName, + inLib, isSim, + e.LocalArtistID, e.LocalReleaseGroupID, e.LocalRecordingID, + discogFetched, + currentSchemaVersion, ); err != nil { - si.logger.Warn("search index: insert error", + si.logger.Warn("search index: upsert error", "mbid", e.MBID, "error", err, ) @@ -1505,9 +2569,58 @@ func (si *SearchIndex) writeBatch(entries []SearchIndexResult) { // Helpers // --------------------------------------------------------------------------- +// unindexedArtistEntries returns artist rows that exist in the +// index but haven't had their full discography fetched yet +// (discog_fetched = 0). Excludes anything in the indexed set. +// Used by the repair pass to heal gaps from AddFromCache or +// from previous incomplete runs. +func (si *SearchIndex) unindexedArtistEntries(indexed map[string]bool) []lbSitewideArtist { + rows, err := si.db.QueryContext(` + SELECT mbid, title, popularity + FROM explore_index + WHERE entity_type = 'artist' AND discog_fetched = 0 + `) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + var out []lbSitewideArtist + + for rows.Next() { + var ( + mbid string + name string + pop int + ) + + if err := rows.Scan(&mbid, &name, &pop); err != nil { + continue + } + + if indexed[mbid] { + continue + } + + out = append(out, lbSitewideArtist{ + ArtistMBID: mbid, + ArtistName: name, + ListenCount: pop, + }) + } + + return out +} + +// indexedArtistMBIDs returns artist MBIDs that have had their full +// discography fetched by the indexer pipeline. Used by tier 2/3 to +// skip artists already processed. Excludes artist rows that only +// got into the index via AddFromCache (frontend organic growth) — +// those are missing recordings and need a real indexer pass. func (si *SearchIndex) indexedArtistMBIDs() map[string]bool { rows, err := si.db.QueryContext( - "SELECT DISTINCT artist_mbid FROM explore_index WHERE entity_type = 'artist'", + "SELECT DISTINCT artist_mbid FROM explore_index WHERE entity_type = 'artist' AND discog_fetched = 1", ) if err != nil { return nil @@ -1630,16 +2743,78 @@ func filterUnindexed(artists []lbSitewideArtist, indexed map[string]bool) []lbSi } // markInLibrary sets in_library=1 for all index entries whose -// artist_mbid matches one of the given artists. +// artist_mbid matches one of the given artists. Also populates +// the local_artist_id cross-reference column. func (si *SearchIndex) markInLibrary(artists []lbSitewideArtist) { for _, a := range artists { _, _ = si.db.ExecContext( - "UPDATE explore_index SET in_library = 1 WHERE artist_mbid = ?", - a.ArtistMBID, + `UPDATE explore_index + SET in_library = 1, + local_artist_id = (SELECT id FROM artists WHERE mbid = ?) + WHERE artist_mbid = ?`, + a.ArtistMBID, a.ArtistMBID, ) } } +// PopulateLocalCrossReferences walks the library tables and updates +// explore_index rows to set local_*_id columns for any MBIDs that +// exist locally. Call after a library scan completes. +func (si *SearchIndex) PopulateLocalCrossReferences() { + // Artists. + if _, err := si.db.ExecContext(` + UPDATE explore_index + SET local_artist_id = ( + SELECT a.id FROM artists a + WHERE a.mbid = explore_index.mbid + ), + in_library = CASE + WHEN EXISTS (SELECT 1 FROM artists WHERE mbid = explore_index.mbid) + THEN 1 ELSE in_library + END + WHERE entity_type = 'artist' + AND mbid IN (SELECT mbid FROM artists WHERE mbid IS NOT NULL AND mbid != '') + `); err != nil { + si.logger.Warn("cross-ref: update artists failed", "error", err) + } + + // Release groups. + if _, err := si.db.ExecContext(` + UPDATE explore_index + SET local_release_group_id = ( + SELECT rg.id FROM release_groups rg + WHERE rg.mbid = explore_index.mbid + ), + in_library = CASE + WHEN EXISTS (SELECT 1 FROM release_groups WHERE mbid = explore_index.mbid) + THEN 1 ELSE in_library + END + WHERE entity_type = 'release_group' + AND mbid IN (SELECT mbid FROM release_groups WHERE mbid IS NOT NULL AND mbid != '') + `); err != nil { + si.logger.Warn("cross-ref: update release groups failed", "error", err) + } + + // Recordings. + if _, err := si.db.ExecContext(` + UPDATE explore_index + SET local_recording_id = ( + SELECT r.id FROM recordings r + WHERE r.mbid = explore_index.mbid + ), + in_library = CASE + WHEN EXISTS (SELECT 1 FROM recordings WHERE mbid = explore_index.mbid) + THEN 1 ELSE in_library + END + WHERE entity_type = 'recording' + AND mbid IN (SELECT mbid FROM recordings WHERE mbid IS NOT NULL AND mbid != '') + `); err != nil { + si.logger.Warn("cross-ref: update recordings failed", "error", err) + } + + si.logger.Info("cross-ref: populated local_*_id columns") +} + // storeSimilarArtists persists the similar artist relationships // for a source artist into the similar_artist_map table. func (si *SearchIndex) storeSimilarArtists(sourceMBID string, similar []lbSimilarArtistWire) { @@ -1682,6 +2857,103 @@ func (si *SearchIndex) markSimilar(artists []lbSitewideArtist) { } } +// 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, + 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 + var score int + + if err := rows.Scan(&mbid, &score); err == nil { + result[mbid] = score + } + } + + return result +} + // InvalidateDiscographies clears the discography build timestamps // so the next build re-runs Tiers 2-4. func (si *SearchIndex) InvalidateDiscographies() { @@ -1725,7 +2997,13 @@ func (si *SearchIndex) MarkReadyIfPopulated() { // scaledLimits returns the number of release groups and recordings // to index for an artist with the given listen count, scaled by // popularity relative to the most popular artist in the index. -func (si *SearchIndex) scaledLimits(listenCount int) (rgs, recs int) { +// If forceMax is true, returns the maximum limits regardless of +// popularity (used for library artists). +func (si *SearchIndex) scaledLimits(listenCount int, forceMax bool) (rgs, recs int) { + if forceMax { + return indexMaxRGs, indexMaxRecs + } + si.mu.RLock() maxL := si.maxListens si.mu.RUnlock() diff --git a/backend/explore/types.go b/backend/explore/types.go index 4065852..1e9b7a9 100644 --- a/backend/explore/types.go +++ b/backend/explore/types.go @@ -13,6 +13,28 @@ type MBSearchResult struct { Artists []MBArtist `json:"artists,omitempty"` ReleaseGroups []MBReleaseGroup `json:"releaseGroups,omitempty"` Recordings []MBRecording `json:"recordings,omitempty"` + TopResults []TopResult `json:"topResults,omitempty"` +} + +// TopResult represents a single top-result card shown above the +// categorized search lists. Computed by intent scoring after all +// reranking is complete. +type TopResult struct { + EntityType string `json:"entityType"` // "artist", "release_group", "recording" + MBID string `json:"mbid"` + Name string `json:"name"` + ArtistCredit string `json:"artistCredit,omitempty"` // for tracks/albums + IntentScore float64 `json:"intentScore"` + // Artist-specific + ArtistType string `json:"artistType,omitempty"` // "Group", "Person" + Country string `json:"country,omitempty"` + // Album-specific + PrimaryType string `json:"primaryType,omitempty"` + Year string `json:"year,omitempty"` + // Track-specific + Length int `json:"length,omitempty"` + // Library status — populated from index cross-reference columns. + InLibrary bool `json:"inLibrary"` } // MBArtist is a Wails-friendly projection of a MusicBrainz artist. @@ -25,9 +47,12 @@ type MBArtist struct { Country string `json:"country"` Disambiguation string `json:"disambiguation"` Score int `json:"score"` - OriginalScore int `json:"-"` // MB search relevance, preserved across reranking - HasPopularity bool `json:"-"` // true if LB/index had listen data for this artist - Popularity int `json:"-"` // raw LB listen count (0 if unknown) + OriginalScore int `json:"-"` // MB search relevance, preserved across reranking + HasPopularity bool `json:"-"` // true if LB/index had listen data for this artist + Popularity int `json:"popularity"` // raw LB listen count (0 if unknown) + ListenerCount int `json:"listenerCount"` + InLibrary bool `json:"inLibrary"` // true if the user owns music by this artist + LocalID int64 `json:"localId,omitempty"` // local artist row ID for navigation } // MBReleaseGroup is a Wails-friendly projection of a MusicBrainz @@ -39,7 +64,11 @@ type MBReleaseGroup struct { SecondaryTypes []string `json:"secondaryTypes,omitempty"` FirstReleaseDate string `json:"firstReleaseDate"` ArtistCredit string `json:"artistCredit"` - Score int `json:"-"` // MB search relevance, used for reranking + Score int `json:"-"` // MB search relevance, used for reranking + Popularity int `json:"popularity"` // raw LB listen count (0 if unknown) + ListenerCount int `json:"listenerCount"` + InLibrary bool `json:"inLibrary"` // true if the user owns this album + LocalID int64 `json:"localId,omitempty"` // local release_group row ID } // MBRelease is a Wails-friendly projection of a MusicBrainz release. @@ -55,11 +84,15 @@ type MBRelease struct { // MBRecording is a Wails-friendly projection of a MusicBrainz // recording. type MBRecording struct { - MBID string `json:"mbid"` - Title string `json:"title"` - Length int `json:"length"` - ArtistCredit string `json:"artistCredit"` - Score int `json:"score"` + MBID string `json:"mbid"` + Title string `json:"title"` + Length int `json:"length"` + ArtistCredit string `json:"artistCredit"` + Score int `json:"score"` + Popularity int `json:"popularity"` // raw LB listen count (0 if unknown) + ListenerCount int `json:"listenerCount"` + InLibrary bool `json:"inLibrary"` // true if the user owns this recording + LocalID int64 `json:"localId,omitempty"` // local recording row ID } // MBTrack is a Wails-friendly projection of a MusicBrainz track. @@ -69,6 +102,8 @@ type MBTrack struct { Title string `json:"title"` Length int `json:"length"` MBID string `json:"mbid"` + InLibrary bool `json:"inLibrary"` + LocalID int64 `json:"localId,omitempty"` } // LBTopRecording represents a popular recording from the @@ -82,6 +117,11 @@ type LBTopRecording struct { ArtistName string `json:"artistName"` TrackName string `json:"trackName"` TotalListenCount int `json:"totalListenCount"` + CAAReleaseMBID string `json:"caaReleaseMbid"` + ReleaseName string `json:"releaseName"` + Length int `json:"length"` // milliseconds (from LB API) + InLibrary bool `json:"inLibrary"` + LocalID int64 `json:"localId,omitempty"` } // lbTopRecordingWire matches the ListenBrainz API's snake_case @@ -92,6 +132,9 @@ type lbTopRecordingWire struct { ArtistName string `json:"artist_name"` RecordingName string `json:"recording_name"` TotalListenCount int `json:"total_listen_count"` + CAAReleaseMBID string `json:"caa_release_mbid"` + ReleaseName string `json:"release_name"` + Length int `json:"length"` // milliseconds } func (w lbTopRecordingWire) toPublic() LBTopRecording { @@ -100,6 +143,9 @@ func (w lbTopRecordingWire) toPublic() LBTopRecording { ArtistName: w.ArtistName, TrackName: w.RecordingName, TotalListenCount: w.TotalListenCount, + CAAReleaseMBID: w.CAAReleaseMBID, + ReleaseName: w.ReleaseName, + Length: w.Length, } } @@ -120,6 +166,9 @@ type LBTopReleaseGroup struct { Type string `json:"type"` Date string `json:"date"` TotalListenCount int `json:"totalListenCount"` + CAAReleaseMBID string `json:"caaReleaseMbid"` + InLibrary bool `json:"inLibrary"` + LocalID int64 `json:"localId,omitempty"` } // lbTopReleaseGroupWire matches the ListenBrainz API's snake_case @@ -129,9 +178,10 @@ type lbTopReleaseGroupWire struct { ReleaseGroupMBID string `json:"release_group_mbid"` TotalListenCount int `json:"total_listen_count"` ReleaseGroup struct { - Name string `json:"name"` - Type string `json:"type"` - Date string `json:"date"` + Name string `json:"name"` + Type string `json:"type"` + Date string `json:"date"` + CAAReleaseMBID string `json:"caa_release_mbid"` } `json:"release_group"` Artist struct { Artists []struct { @@ -153,5 +203,6 @@ func (w lbTopReleaseGroupWire) toPublic() LBTopReleaseGroup { Type: w.ReleaseGroup.Type, Date: w.ReleaseGroup.Date, TotalListenCount: w.TotalListenCount, + CAAReleaseMBID: w.ReleaseGroup.CAAReleaseMBID, } } diff --git a/backend/library/library.go b/backend/library/library.go index e30b015..e92038c 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -76,6 +76,10 @@ type RescanHooks struct { // completes. The app layer wires these so the library package // does not depend on the playlist package directly. type ScanHooks struct { + // RepopulatePlaylists re-imports tracks for playlists that + // lost their playlist_tracks rows (e.g., from a pre-fix + // FullRescan). Runs before ResolvePhantoms. + RepopulatePlaylists func() // ResolvePhantoms re-links phantom playlist tracks whose // files now exist in the library after scanning. ResolvePhantoms func() @@ -694,10 +698,13 @@ func (l *Library) scanInternal( metrics.OrphanCleanup = time.Since(orphanStart) } - // --- Phase 6: resolve phantom playlist tracks --- - // Delegated to the playlist service via ScanHooks so that - // M3U8-based path resolution can handle both pre-existing - // phantoms (no phantom_file_path) and new ones. + // --- Phase 6: repopulate + resolve phantom playlist tracks --- + // Repopulate first: re-imports tracks for playlists that lost + // their rows (from a pre-fix FullRescan that deleted them). + if !cancelled && l.scanHooks.RepopulatePlaylists != nil { + l.scanHooks.RepopulatePlaylists() + } + // Then resolve: re-links phantom tracks to audio_files. if !cancelled && l.scanHooks.ResolvePhantoms != nil { l.scanHooks.ResolvePhantoms() } diff --git a/backend/library/query.go b/backend/library/query.go index 0a3ef77..e35a250 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -44,6 +44,10 @@ type Track struct { RecordingMBID string ArtistMBID string ReleaseGroupMBID string + CoverArtPath string + CoverArtSmall string + CoverArtMedium string + CoverArtLarge string } // genreDelimiter is the separator used by GROUP_CONCAT in the @@ -74,13 +78,15 @@ func mapTrackRow( sampleRate, bitDepth, channels, bitrate, fileSize int64, playCount int64, lastPlayed sql.NullTime, + coverArtPath string, + artistMBID, releaseGroupMBID, recordingMBID string, ) Track { var lastPlayedStr string if lastPlayed.Valid { lastPlayedStr = lastPlayed.Time.Format(time.DateTime) } - return Track{ + t := Track{ TrackName: title, ArtistName: artistName, TrackLength: strconv.FormatInt(lengthMs, 10), @@ -97,9 +103,22 @@ func mapTrackRow( Channels: channels, Bitrate: bitrate, FileSize: fileSize, - PlayCount: playCount, - LastPlayed: lastPlayedStr, + PlayCount: playCount, + LastPlayed: lastPlayedStr, + ArtistMBID: artistMBID, + ReleaseGroupMBID: releaseGroupMBID, + RecordingMBID: recordingMBID, } + + if coverArtPath != "" { + urls := coverart.ResolveURLs(coverArtPath) + t.CoverArtPath = urls.Original + t.CoverArtSmall = urls.Small + t.CoverArtMedium = urls.Medium + t.CoverArtLarge = urls.Large + } + + return t } // TrackMBIDs holds MusicBrainz identifiers for a track, resolved @@ -210,6 +229,10 @@ func (l *Library) GetAllTracks() ([]Track, error) { row.FileSize, row.PlayCount, row.LastPlayed, + row.CoverArtPath, + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, )) } @@ -263,6 +286,8 @@ func (l *Library) SearchTracks( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + "", "", "", )) } @@ -303,6 +328,10 @@ func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) { row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, )) } @@ -548,6 +577,8 @@ func (l *Library) GetTracksByGenre( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + "", "", "", )) } @@ -631,6 +662,10 @@ func (l *Library) GetAllTracksByLibrary( row.FileSize, row.PlayCount, row.LastPlayed, + row.CoverArtPath, + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, )) } @@ -876,6 +911,8 @@ func (l *Library) GetTracksByGenreByLibrary( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + "", "", "", )) } @@ -928,6 +965,10 @@ func (l *Library) GetAlbumTracksByLibrary( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, )) } @@ -976,6 +1017,8 @@ func (l *Library) SearchTracksByLibrary( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + "", "", "", )) } diff --git a/backend/library/rescan.go b/backend/library/rescan.go index f538e08..b8e75bb 100644 --- a/backend/library/rescan.go +++ b/backend/library/rescan.go @@ -124,9 +124,44 @@ func (l *Library) clearLibraryTables() error { return fmt.Errorf("could not clear queue tracks: %w", err) } - if err := txq.DeleteAllPlaylistTracks(l.ctx); err != nil { + // Preserve playlist tracks across rescan: populate phantom + // metadata for all linked tracks before audio_files are deleted. + // ON DELETE SET NULL will null out audio_file_id, converting them + // to phantoms that ResolvePhantomTracksAfterScan can re-link. + if _, err := tx.ExecContext(l.ctx, ` + UPDATE playlist_tracks + SET + phantom_title = COALESCE(phantom_title, ( + SELECT r.name FROM audio_files af + JOIN recordings r ON af.recording_id = r.id + WHERE af.id = playlist_tracks.audio_file_id + )), + phantom_artist = COALESCE(phantom_artist, ( + SELECT ac.text FROM audio_files af + JOIN recordings r ON af.recording_id = r.id + JOIN artist_credit ac ON r.artist_credit_id = ac.id + WHERE af.id = playlist_tracks.audio_file_id + )), + phantom_album = COALESCE(phantom_album, ( + SELECT rg.name FROM audio_files af + JOIN recordings r ON af.recording_id = r.id + LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + WHERE af.id = playlist_tracks.audio_file_id + LIMIT 1 + )), + phantom_duration_ms = COALESCE(phantom_duration_ms, ( + SELECT af.length_milliseconds FROM audio_files af + WHERE af.id = playlist_tracks.audio_file_id + )), + phantom_file_path = COALESCE(phantom_file_path, ( + SELECT af.file_path FROM audio_files af + WHERE af.id = playlist_tracks.audio_file_id + )) + WHERE audio_file_id IS NOT NULL + `); err != nil { return fmt.Errorf( - "could not clear playlist tracks: %w", err, + "could not preserve playlist track metadata: %w", err, ) } diff --git a/backend/player/player.go b/backend/player/player.go index c4eb1c9..d3243da 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -79,19 +79,22 @@ const ( // event and serialized as camelCase JSON to match the frontend // TrackInfo interface in player-store.ts. type TrackInfo struct { - FileName string `json:"fileName"` - FilePath string `json:"filePath"` - State State `json:"state"` - Title string `json:"title"` - Artist string `json:"artist"` - Album string `json:"album"` - CoverArt string `json:"coverArt"` - CoverArtSmall string `json:"coverArtSmall"` - CoverArtMedium string `json:"coverArtMedium"` - CoverArtLarge string `json:"coverArtLarge"` - TrackLength int `json:"trackLength"` - SeekPosition int `json:"seekPosition"` - TrackChangeID uint64 `json:"trackChangeId"` + FileName string `json:"fileName"` + FilePath string `json:"filePath"` + State State `json:"state"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + CoverArt string `json:"coverArt"` + CoverArtSmall string `json:"coverArtSmall"` + CoverArtMedium string `json:"coverArtMedium"` + CoverArtLarge string `json:"coverArtLarge"` + TrackLength int `json:"trackLength"` + SeekPosition int `json:"seekPosition"` + TrackChangeID uint64 `json:"trackChangeId"` + ArtistMBID string `json:"artistMbid"` + ReleaseGroupMBID string `json:"releaseGroupMbid"` + RecordingMBID string `json:"recordingMbid"` } // Sentinel errors for player operations. @@ -870,6 +873,9 @@ func (p *Player) getCurrentTrackInfoLocked() TrackInfo { info.Artist = meta.Artist info.Album = meta.Album + info.ArtistMBID = meta.ArtistMbid + info.ReleaseGroupMBID = meta.ReleaseGroupMbid + info.RecordingMBID = meta.RecordingMbid p.trackLengthMs = meta.LengthMilliseconds if meta.CoverArtPath != "" { diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 2d1bc94..b86fb1b 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -56,18 +56,21 @@ type Summary struct { // Track represents a track within a playlist, including its // metadata. type Track struct { - ID int64 `json:"ID"` - Position int64 `json:"Position"` - FilePath string `json:"FilePath"` - Title string `json:"Title"` - Artist string `json:"Artist"` - Album string `json:"Album"` - CoverArtPath string `json:"CoverArtPath"` - CoverArtSmall string `json:"CoverArtSmall"` - CoverArtMedium string `json:"CoverArtMedium"` - CoverArtLarge string `json:"CoverArtLarge"` - Duration string `json:"Duration"` - Phantom bool `json:"Phantom"` + ID int64 `json:"ID"` + Position int64 `json:"Position"` + FilePath string `json:"FilePath"` + Title string `json:"Title"` + Artist string `json:"Artist"` + Album string `json:"Album"` + CoverArtPath string `json:"CoverArtPath"` + CoverArtSmall string `json:"CoverArtSmall"` + CoverArtMedium string `json:"CoverArtMedium"` + CoverArtLarge string `json:"CoverArtLarge"` + Duration string `json:"Duration"` + Phantom bool `json:"Phantom"` + ArtistMBID string `json:"ArtistMBID"` + ReleaseGroupMBID string `json:"ReleaseGroupMBID"` + RecordingMBID string `json:"RecordingMBID"` } // WithTracks contains a playlist summary and all its tracks. @@ -242,6 +245,9 @@ func (s *Service) GetAllPlaylistsWithTracks() ( row.Album, row.LengthMilliseconds, row.CoverArtPath, + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, ) if dbTracksByPlaylist[row.PlaylistID] == nil { @@ -312,6 +318,9 @@ func (s *Service) GetPlaylistTracks( row.Album, row.LengthMilliseconds, row.CoverArtPath, + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, ) dbTracks[row.FilePath] = track @@ -429,15 +438,19 @@ func trackFromRow( filePath, title, artist, album string, lengthMilliseconds int64, coverArtPath string, + artistMBID, releaseGroupMBID, recordingMBID string, ) Track { track := Track{ - ID: id, - Position: position, - FilePath: filePath, - Title: title, - Artist: artist, - Album: album, - Duration: strconv.FormatInt(lengthMilliseconds, 10), + ID: id, + Position: position, + FilePath: filePath, + Title: title, + Artist: artist, + Album: album, + Duration: strconv.FormatInt(lengthMilliseconds, 10), + ArtistMBID: artistMBID, + ReleaseGroupMBID: releaseGroupMBID, + RecordingMBID: recordingMBID, } if coverArtPath != "" { @@ -1499,6 +1512,165 @@ func (s *Service) migrateExistingPlaylists() { } } +// ================================================================= +// Playlist repopulation from M3U8 +// ================================================================= + +// RepopulateFromM3U re-imports tracks for playlists that have zero +// playlist_tracks rows but still have a corresponding M3U8 file. +// This recovers from a FullRescan that deleted playlist tracks +// before the ON DELETE SET NULL fix was in place. Each M3U8 entry +// is resolved against the audio_files table; unresolved entries +// become phantom tracks with metadata preserved from the M3U8. +func (s *Service) RepopulateFromM3U() { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "could not get playlists dir for repopulation", + "err", err, + ) + return + } + + // Get all playlists. + playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx) + if err != nil { + s.logger.Warn("could not get playlists for repopulation", "err", err) + return + } + + libraryRoots := s.getAllLibraryRoots() + + // Build audio file path→ID map for resolution. + afRows, err := s.db.QueryContext( + `SELECT id, file_path FROM audio_files`, + ) + if err != nil { + s.logger.Warn("could not query audio files for repopulation", "err", err) + return + } + + audioFileByPath := make(map[string]int64) + for afRows.Next() { + var id int64 + var fp string + if err := afRows.Scan(&id, &fp); err != nil { + continue + } + audioFileByPath[fp] = id + } + _ = afRows.Close() + + knownPaths := make(map[string]struct{}, len(audioFileByPath)) + for k := range audioFileByPath { + knownPaths[k] = struct{}{} + } + + var totalRepopulated int + + for _, pl := range playlists { + // Only repopulate playlists with zero tracks. + countRows, err := s.db.QueryContext( + `SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ?`, + pl.ID, + ) + if err != nil { + continue + } + var count int + if countRows.Next() { + _ = countRows.Scan(&count) + } + _ = countRows.Close() + if count > 0 { + continue + } + + m3uPath, err := findPlaylistFile(dir, pl.ID) + if err != nil || m3uPath == "" { + continue + } + + parsed, err := parseM3U8(m3uPath) + if err != nil { + s.logger.Warn( + "could not parse M3U8 for repopulation", + "playlistId", pl.ID, + "path", m3uPath, + "err", err, + ) + continue + } + + var resolved, phantom int + + for i, entry := range parsed.Entries { + absPath := resolveM3UPath( + entry.RelativePath, libraryRoots, knownPaths, + ) + + audioFileID, exists := audioFileByPath[absPath] + + if exists { + // Linked track. + _, addErr := s.db.ExecContext( + `INSERT INTO playlist_tracks + (playlist_id, audio_file_id, position) + VALUES (?, ?, ?)`, + pl.ID, audioFileID, i, + ) + if addErr != nil { + s.logger.Warn( + "could not add repopulated track", + "playlistId", pl.ID, + "position", i, + "err", addErr, + ) + continue + } + resolved++ + } else { + // Phantom track — preserve what we have from M3U8. + _, addErr := s.db.ExecContext( + `INSERT INTO playlist_tracks + (playlist_id, position, phantom_title, phantom_file_path) + VALUES (?, ?, ?, ?)`, + pl.ID, i, entry.DisplayTitle, absPath, + ) + if addErr != nil { + s.logger.Warn( + "could not add phantom repopulated track", + "playlistId", pl.ID, + "position", i, + "err", addErr, + ) + continue + } + phantom++ + } + } + + if resolved+phantom > 0 { + totalRepopulated += resolved + phantom + s.logger.Info( + "repopulated playlist from M3U8", + "playlistId", pl.ID, + "name", pl.Name, + "resolved", resolved, + "phantom", phantom, + ) + } + } + + if totalRepopulated > 0 { + s.logger.Info( + "playlist repopulation complete", + "totalTracks", totalRepopulated, + ) + s.emitEvent(events.PlaylistTracksChanged, nil) + } +} + // ================================================================= // Phantom track resolution // ================================================================= diff --git a/backend/queue/persistence.go b/backend/queue/persistence.go index 09abeb9..ac0c29e 100644 --- a/backend/queue/persistence.go +++ b/backend/queue/persistence.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "yellowjacket/backend/coverart" "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/profiling" ) @@ -245,10 +246,15 @@ func (q *Queue) lookupChunk( for _, row := range rows { result[row.FilePath] = trackMeta{ - AudioFileID: row.ID, - FilePath: row.FilePath, - Title: row.Title, - Artist: row.ArtistName, + AudioFileID: row.ID, + FilePath: row.FilePath, + Title: row.Title, + Artist: row.ArtistName, + Album: row.Album, + CoverArtPath: row.CoverArtPath, + ArtistMBID: row.ArtistMbid, + ReleaseGroupMBID: row.ReleaseGroupMbid, + RecordingMBID: row.RecordingMbid, } } } @@ -448,13 +454,23 @@ func (q *Queue) RestoreState() { q.tracks = make([]Track, 0, len(rows)) for _, row := range rows { + var coverArtURL string + if row.CoverArtPath != "" { + coverArtURL = coverart.ResolveURLs(row.CoverArtPath).Small + } + q.tracks = append(q.tracks, Track{ - ID: row.ID, - AudioFileID: row.AudioFileID, - FilePath: row.FilePath, - Position: row.Position, - Title: row.Title, - Artist: row.Artist, + ID: row.ID, + AudioFileID: row.AudioFileID, + FilePath: row.FilePath, + Position: row.Position, + Title: row.Title, + Artist: row.Artist, + Album: row.Album, + CoverArtPath: coverArtURL, + ArtistMBID: row.ArtistMbid, + ReleaseGroupMBID: row.ReleaseGroupMbid, + RecordingMBID: row.RecordingMbid, }) } diff --git a/backend/queue/queue.go b/backend/queue/queue.go index 84b68c2..2415d29 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -8,6 +8,7 @@ import ( "sync" "sync/atomic" + "yellowjacket/backend/coverart" "yellowjacket/backend/database" "yellowjacket/backend/profiling" ) @@ -36,20 +37,35 @@ const initialBatchSize = 50 // trackMeta holds the result of a batch metadata lookup. type trackMeta struct { - AudioFileID int64 - FilePath string - Title string - Artist string + AudioFileID int64 + FilePath string + Title string + Artist string + Album string + CoverArtPath string + ArtistMBID string + ReleaseGroupMBID string + RecordingMBID string } // toTrack converts metadata lookup results into a queue Track. func (m trackMeta) toTrack(position int64) Track { + var coverArtURL string + if m.CoverArtPath != "" { + coverArtURL = coverart.ResolveURLs(m.CoverArtPath).Small + } + return Track{ - AudioFileID: m.AudioFileID, - FilePath: m.FilePath, - Position: position, - Title: m.Title, - Artist: m.Artist, + AudioFileID: m.AudioFileID, + FilePath: m.FilePath, + Position: position, + Title: m.Title, + Artist: m.Artist, + Album: m.Album, + CoverArtPath: coverArtURL, + ArtistMBID: m.ArtistMBID, + ReleaseGroupMBID: m.ReleaseGroupMBID, + RecordingMBID: m.RecordingMBID, } } @@ -64,12 +80,17 @@ type TrackLoader interface { // Track represents a track in the queue with its metadata. type Track struct { - ID int64 `json:"id"` - AudioFileID int64 `json:"audioFileId"` - FilePath string `json:"filePath"` - Position int64 `json:"position"` - Title string `json:"title"` - Artist string `json:"artist"` + ID int64 `json:"id"` + AudioFileID int64 `json:"audioFileId"` + FilePath string `json:"filePath"` + Position int64 `json:"position"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + CoverArtPath string `json:"coverArtPath"` + ArtistMBID string `json:"artistMbid"` + ReleaseGroupMBID string `json:"releaseGroupMbid"` + RecordingMBID string `json:"recordingMbid"` } // State is the full state emitted to the frontend. @@ -1274,13 +1295,20 @@ func (q *Queue) CompactAfterLibraryRemoval() { q.tracks = make([]Track, 0, len(rows)) for _, row := range rows { + var coverArtURL string + if row.CoverArtPath != "" { + coverArtURL = coverart.ResolveURLs(row.CoverArtPath).Small + } + q.tracks = append(q.tracks, Track{ - ID: row.ID, - AudioFileID: row.AudioFileID, - FilePath: row.FilePath, - Position: row.Position, - Title: row.Title, - Artist: row.Artist, + ID: row.ID, + AudioFileID: row.AudioFileID, + FilePath: row.FilePath, + Position: row.Position, + Title: row.Title, + Artist: row.Artist, + Album: row.Album, + CoverArtPath: coverArtURL, }) } diff --git a/backend/tracklist/config.go b/backend/tracklist/config.go index c13e17d..c6bdf9d 100644 --- a/backend/tracklist/config.go +++ b/backend/tracklist/config.go @@ -34,11 +34,13 @@ const ( ColBitrate ColumnID = "bitrate" ColFileSize ColumnID = "fileSize" ColPlayCount ColumnID = "playCount" + ColAlbumArt ColumnID = "albumArt" ) // AllColumnIDs lists every recognised column in default display // order. var AllColumnIDs = []ColumnID{ + ColAlbumArt, ColTrackName, ColArtistName, ColTrackLength, diff --git a/frontend/index.ts b/frontend/index.ts index fedd470..eecf4cb 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -66,6 +66,11 @@ const viewCache = new Map(); let currentViewEl: HTMLElement | null = null; let currentDetailEl: HTMLElement | null = null; +/** Navigation history stack for back-button support in detail views. */ +const navStack: Array<{ view: string; [key: string]: any }> = []; +/** The current navigation detail (so we can push it onto the stack). */ +let currentNavDetail: { view: string; [key: string]: any } = { view: 'tracks' }; + // Seed the cache with the default track-list rendered in index.html. const mainContent = document.getElementById('main-content'); @@ -88,6 +93,9 @@ document.addEventListener('navigate', (e: Event) => { // --- Primary (cacheable) views ---------------------------------------- if (view in VIEW_TAGS) { + // Navigating to a primary view clears the history stack. + navStack.length = 0; + // Remove any active detail view first if (currentDetailEl) { currentDetailEl.remove(); @@ -111,10 +119,17 @@ document.addEventListener('navigate', (e: Event) => { } target.classList.remove('view-hidden'); currentViewEl = target; + currentNavDetail = { view }; return; } // --- Detail (ephemeral) views ----------------------------------------- + // Push the current view onto the nav stack before switching + // (unless this is a back-navigation, which already popped). + if (!detail._isBack) { + navStack.push({ ...currentNavDetail }); + } + // Hide the current primary view if (currentViewEl) { currentViewEl.classList.add('view-hidden'); @@ -125,6 +140,8 @@ document.addEventListener('navigate', (e: Event) => { currentDetailEl = null; } + currentNavDetail = { ...detail }; + switch (view) { case 'artist-details': { const { artistId, artistName } = detail; @@ -169,21 +186,27 @@ document.addEventListener('navigate', (e: Event) => { break; } case 'explore-artist-details': { - const { artistMBID, artistName } = detail; + const { artistMBID, artistName, localArtistId } = detail; const el = document.createElement('explore-artist-details'); - el.setAttribute('artist-mbid', artistMBID); + if (artistMBID) el.setAttribute('artist-mbid', artistMBID); el.setAttribute('artist-name', artistName); + if (localArtistId) el.setAttribute('local-artist-id', String(localArtistId)); mainContent.appendChild(el); currentDetailEl = el; break; } case 'explore-album-details': { - const { releaseGroupMBID, albumName } = detail; + const { releaseGroupMBID, albumName, artistName, highlightTrackMBID, localAlbumId } = detail; const el = document.createElement('explore-album-details'); - el.setAttribute('release-group-mbid', releaseGroupMBID); + if (releaseGroupMBID) el.setAttribute('release-group-mbid', releaseGroupMBID); el.setAttribute('album-name', albumName); + if (artistName) el.setAttribute('artist-name', artistName); + if (highlightTrackMBID) { + el.setAttribute('highlight-track-mbid', highlightTrackMBID); + } + if (localAlbumId) el.setAttribute('local-album-id', String(localAlbumId)); mainContent.appendChild(el); currentDetailEl = el; break; @@ -200,6 +223,18 @@ document.addEventListener('navigate', (e: Event) => { } }); +// Navigate-back: pop the nav stack and re-dispatch as a regular navigate. +document.addEventListener('navigate-back', () => { + const prev = navStack.pop(); + if (prev) { + document.dispatchEvent(new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { ...prev, _isBack: true }, + })); + } +}); + // Queue panel toggle const queueButton = document.getElementById('queue-button'); const queuePanel = document.getElementById('queue-panel') as HTMLElement | null; diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index d3946a4..1dd0388 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -842,9 +842,10 @@ export class ArtistsView bubbles: true, composed: true, detail: { - view: 'artist-details', - artistId: artist.ID, + view: 'explore-artist-details', + artistMBID: artist.MBID || '', artistName: artist.Name, + localArtistId: artist.ID, }, }), ); @@ -1089,11 +1090,13 @@ export class ArtistsView bubbles: true, composed: true, detail: { - view: 'artist-details', - artistId: - artist.ID, + view: 'explore-artist-details', + artistMBID: + artist.MBID || '', artistName: artist.Name, + localArtistId: + artist.ID, }, }, ), diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index 759a17e..ce0b3b3 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { EventsOn } from '@runtime/runtime'; +import type { explore } from '@go/models'; import { FullRescan, CancelCurrentScan, @@ -363,6 +364,7 @@ export class ConfigPage extends LitElement { @state() private showCancelDialog = false; @state() private cancelMetrics: { added: number } | null = null; @state() private scanQueuedCount = 0; + @state() private indexStatus: explore.IndexStatus | null = null; @state() private shortcutConflict: { newAction: string; newKey: string; @@ -376,6 +378,8 @@ export class ConfigPage extends LitElement { private cancelScanPaused?: () => void; private cancelScanResumed?: () => void; private cancelScanCancelled?: () => void; + private cancelIndexStatus?: () => void; + private indexPollTimer?: ReturnType; private cancelScanQueued?: () => void; private cancelScanQueueDrained?: () => void; private cancelLibraryAdded?: () => void; @@ -1097,6 +1101,70 @@ export class ConfigPage extends LitElement { flex-shrink: 0; } + /* Search index status */ + .index-status { + padding: 0 0.25em 0.5em; + } + + .index-stats { + display: flex; + align-items: center; + gap: 0.5em; + font-size: var(--yj-text-sm); + color: var(--yj-text-secondary, #aaa); + margin-bottom: 1em; + font-variant-numeric: tabular-nums; + } + + .index-stat-sep { + opacity: 0.4; + } + + .index-tiers { + display: flex; + flex-direction: column; + gap: 0.5em; + } + + .index-tier { + display: flex; + align-items: center; + gap: 0.6em; + font-size: var(--yj-text-sm); + } + + .tier-icon { + width: 1.2em; + text-align: center; + flex-shrink: 0; + } + + .tier-name { + color: var(--yj-text-primary, #fff); + } + + .tier-progress { + color: var(--yj-text-tertiary, #888); + font-size: var(--yj-text-xs, 11px); + font-variant-numeric: tabular-nums; + } + + .tier-error { + color: var(--yj-accent-error, #f44); + font-size: var(--yj-text-xs, 11px); + } + + .index-ready { + margin-top: 1em; + font-size: var(--yj-text-sm); + color: var(--yj-accent-success, #4a4); + } + + .index-waiting, .index-loading { + font-size: var(--yj-text-sm); + color: var(--yj-text-tertiary, #888); + } + `; // =================================================================== @@ -1156,6 +1224,15 @@ export class ConfigPage extends LitElement { ); document.addEventListener('click', this.handleDocumentClick); + + // Listen for index status events (pushed from Go, no binding calls). + this.cancelIndexStatus = EventsOn( + Events.IndexStatusChanged, + (status: explore.IndexStatus) => { + console.log('IndexStatusChanged event received', status); + this.indexStatus = status; + }, + ); } override disconnectedCallback(): void { @@ -1175,6 +1252,8 @@ export class ConfigPage extends LitElement { document.removeEventListener('click', this.handleDocumentClick); if (this.toastTimer) clearTimeout(this.toastTimer); + if (this.indexPollTimer) clearInterval(this.indexPollTimer); + this.cancelIndexStatus?.(); } private async loadLibraries(): Promise { @@ -1848,6 +1927,7 @@ export class ConfigPage extends LitElement { return html`

Settings

+ ${this.renderSearchSection()} ${this.renderNowPlayingSection()} ${this.renderThemeSection()} ${this.renderFavoritesSection()} @@ -1857,6 +1937,106 @@ export class ConfigPage extends LitElement { `; } + // --- Search / Index section --- + + private async pollIndexStatus(): Promise { + // Kept as no-op — status comes via events now. + } + + private renderSearchSection() { + const s = this.indexStatus; + + return html` + +
+ ${s + ? html` +
+ ${this.formatCount(s.artists)} artists + · + ${this.formatCount(s.recordings)} recordings + · + ${this.formatCount(s.releaseGroups)} albums + · + ${this.formatCount(s.totalRows)} total + ${s.lastBuilt + ? html`· + updated ${this.timeAgo(s.lastBuilt)}` + : nothing} +
+ ${s.tiers?.length > 0 && s.tiers.some((t) => t.state === 'running' || t.state === 'pending' || t.state === 'error') + ? html` +
+ ${s.tiers.map( + (t) => html` +
+ ${this.tierIcon(t.state)} + ${t.name} + ${t.state === 'running' && t.total > 0 + ? html`${t.completed}/${t.total}` + : nothing} + ${t.state === 'error' + ? html`${t.error}` + : nothing} +
+ `, + )} +
+ ` + : nothing} + ${!s.building && s.ready + ? html`
Index ready
` + : !s.building && !s.ready && s.totalRows === 0 + ? html`
Index empty — build will start after library scan
` + : !s.building && !s.ready + ? html`
Waiting for index build…
` + : nothing} + ` + : html`
Loading status…
`} +
+
+ `; + } + + private tierIcon(state: string): string { + switch (state) { + case 'complete': + case 'skipped': + return '✅'; + case 'running': + return '🔄'; + case 'error': + return '❌'; + case 'pending': + default: + return '⏳'; + } + } + + private formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return `${n}`; + } + + private timeAgo(iso: string): string { + const then = new Date(iso).getTime(); + if (!then) return ''; + const seconds = Math.floor((Date.now() - then) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return 'yesterday'; + return `${days}d ago`; + } + // --- Now Playing section --- private renderNowPlayingSection() { diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index e420739..e8c8f3d 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -1084,7 +1084,6 @@ export class CoverGrid } this.selectedAlbums = next; - this.syncDropdownToSelection(); void this.selMgr.warmCache( this.selectedAlbums, ); @@ -1099,31 +1098,23 @@ export class CoverGrid this.selectedAlbums = next; this.lastSelectedAlbumIndex = index; - this.syncDropdownToSelection(); void this.selMgr.warmCache( this.selectedAlbums, ); } else { - // Plain click: if this album is the - // sole selection, deselect + close. - // Otherwise select only this album - // and open its dropdown. - if ( - this.selectedAlbums.size === 1 && - this.selectedAlbums.has(album.ID) - ) { - this.selectedAlbums = new Set(); - this.closeDropdown(); - } else { - this.selectedAlbums = new Set([ - album.ID, - ]); - void this.openDropdown(album); - } - - this.lastSelectedAlbumIndex = index; - void this.selMgr.warmCache( - this.selectedAlbums, + // Plain click: navigate to explore album page. + this.selectedAlbums = new Set(); + this.dispatchEvent( + new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { + view: 'explore-album-details', + releaseGroupMBID: album.MBID || '', + albumName: album.Name, + localAlbumId: album.ID, + }, + }), ); } }; @@ -1897,9 +1888,7 @@ export class CoverGrid `; } - const gridContent = this.splitMode - ? this.renderSplitGrid() - : this.renderSingleGrid(); + const gridContent = this.renderSingleGrid(); return html` ${this.renderSortToolbar()} diff --git a/frontend/src/components/explore-album-details/explore-album-details.ts b/frontend/src/components/explore-album-details/explore-album-details.ts index 1de64cd..5342817 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -4,15 +4,20 @@ import { designTokens } from '../../styles/tokens.css'; import { LookupReleaseGroup, BrowseReleases, + GetThumbnail, } from '@go/explore/Service'; import type { MBReleaseGroup, MBRelease, MBTrack, } from '@go/explore/Service'; +import { GetAlbumTracks } from '@go/library/Library'; +import { library } from '@go/models'; import { exploreCache } from '../../store/explore-cache'; import { exploreSettings } from '../../store/explore-settings'; +import { libraryStore } from '../../store/library-store'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import '../library-status-indicator/library-status-indicator.js'; /* ── Constants ── */ const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group'; @@ -54,6 +59,35 @@ interface ReleaseCluster { representative: MBRelease; allReleases: MBRelease[]; fingerprint: string; + /** Standard-version score, computed by buildClusters. */ + score: number; +} + +/** Synthetic kinds — virtual entries pinned at the top of the dropdown. */ +type SyntheticKind = 'standard' | 'comprehensive' | 'library'; + +/** Unified version-entry shape covering both synthetic and real clusters. */ +interface VersionEntry { + /** Stable identifier for the dropdown