diff --git a/Makefile b/Makefile index 1737374..5ef4bf1 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,10 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' dev: setup generate clean - go tool wails dev -tags webkit2_41 -loglevel Debug -v 2 + if [ -f .env ]; then set -a; . ./.env; set +a; fi; go tool wails dev -tags webkit2_41 -loglevel Debug -v 2 dev-debug: setup generate clean - YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2 + if [ -f .env ]; then set -a; . ./.env; set +a; fi; YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2 build-dev: generate go tool wails build -tags webkit2_41 -debug -clean -ldflags "$(LDFLAGS)" diff --git a/backend/app.go b/backend/app.go index 93bd287..0bcb8e9 100644 --- a/backend/app.go +++ b/backend/app.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "log/slog" + "net/http" + "path/filepath" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -15,6 +17,7 @@ import ( "yellowjacket/backend/config" "yellowjacket/backend/coverart" "yellowjacket/backend/database" + "yellowjacket/backend/explore" "yellowjacket/backend/frontendutil" "yellowjacket/backend/library" "yellowjacket/backend/mediacontrols" @@ -22,6 +25,7 @@ import ( "yellowjacket/backend/playlist" "yellowjacket/backend/profiling" "yellowjacket/backend/queue" + "yellowjacket/backend/system" "yellowjacket/backend/tagwriter" ) @@ -37,6 +41,7 @@ type YellowJacketApp struct { player *player.Player playlist *playlist.Service queue *queue.Queue + explore *explore.Service mediaControls mediacontrols.Handler tagWriter *tagwriter.TagWriter appContext context.Context @@ -102,6 +107,17 @@ func NewYellowJacketApp( yjApp.assetHandler.RegisterHandler(coverart.PathPrefix, coverHandler) + // Register artist image handler for serving cached artist photos. + artistImgDir, err := system.GetUserDataDirPath() + if err == nil { + artistImgHandler := http.StripPrefix( + "/artist-images/", + http.FileServer(http.Dir(filepath.Join(artistImgDir, "artist-images"))), + ) + + yjApp.assetHandler.RegisterHandler("/artist-images/", artistImgHandler) + } + // create playlist service yjApp.playlist = playlist.NewService( yjApp.logger, yjApp.database, yjApp.appConfig, @@ -125,6 +141,11 @@ func NewYellowJacketApp( yjApp.library, ) + // create explore service + yjApp.explore = explore.NewExploreService( + yjApp.logger.WithGroup("explore"), yjApp.database, + ) + yjApp.FEBindings = []any{ yjApp.FrontendUtil, yjApp.appConfig, @@ -133,6 +154,7 @@ func NewYellowJacketApp( yjApp.queue, yjApp.player, yjApp.tagWriter, + yjApp.explore, } return yjApp, nil @@ -167,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). @@ -179,6 +203,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { yj.player.SetContext(ctx) yj.tagWriter.SetContext(ctx) + yj.explore.SetContext(ctx) // Wire queue (created in NewYellowJacketApp for Wails binding) yj.queue.SetContext(ctx) @@ -189,14 +214,41 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { // orchestrate queue clearing and playlist restoration // without depending on those packages directly. yj.library.SetRescanHooks(library.RescanHooks{ - PreClear: yj.queue.Clear, - PostScan: yj.playlist.RestoreAllPlaylists, + PreClear: func() { + yj.queue.Clear() + // Stop the search index build so it doesn't fight + // with the rescan for DB access. + yj.explore.StopIndexBuild() + }, + PostScan: func() { + yj.playlist.RestoreAllPlaylists() + // DON'T restart the index build here — queued + // library scans may still be running. The index + // build starts after ALL scans complete (via the + // scan hooks below). + }, }) // 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() { + // 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() + }, }) // Wire removal hooks so the library can stop playback and @@ -262,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 @@ -310,5 +369,12 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) { if err := yj.library.SoftScanAllLibraries(); err != nil { yj.logger.Error("soft scan failed", "err", err) } + + // If no scans were queued (library unchanged), start the + // index build directly. If scans WERE queued, the + // OnAllScansComplete hook starts it after they finish. + if yj.library.GetScanQueueLength() == 0 && !yj.library.IsScanActive() { + yj.explore.StartIndexBuild() + } }() } 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 507e3d2..075fbd4 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -355,10 +355,576 @@ func runMigrations( } } + // Migration 11: explore_cache table for MusicBrainz/ListenBrainz + // API response caching with TTL expiry and MBID lookups. + if version < 11 { + if err := migration11ExploreCache( + ctx, db, logger, + ); err != nil { + return err + } + } + + // Migration 12: explore_index + FTS5 for the popularity search + // index. Stores the top albums and tracks from the most popular + // ListenBrainz artists for instant local search. + if version < 12 { //nolint:mnd + if err := migration12ExploreSearchIndex( + ctx, db, logger, + ); err != nil { + return err + } + } + + // Migration 13: add MusicBrainz ID columns to artists, + // release_groups, and recordings for library↔explore linking. + if version < 13 { //nolint:mnd + if err := migration13MBIDColumns( + ctx, db, logger, + ); err != nil { + return err + } + } + + // Migration 14: add aliases column to explore_index and rebuild + // the FTS5 virtual table with 3 searchable columns. + if version < 14 { //nolint:mnd + if err := migration14ExploreAliases( + ctx, db, logger, + ); err != nil { + return err + } + } + + // Migration 15: add in_library and is_similar columns to + // explore_index for personalized search ranking. + if version < 15 { //nolint:mnd + if err := migration15PersonalizationColumns( + ctx, db, logger, + ); err != nil { + return err + } + } + + // Migration 16: artist_images table for multi-source artist photos. + if version < 16 { //nolint:mnd + if err := migration16ArtistImages( + ctx, db, logger, + ); err != nil { + return err + } + } + + if version < 17 { //nolint:mnd + if err := migration17SimilarArtistMap( + ctx, db, logger, + ); err != nil { + return err + } + } + + 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( @@ -586,10 +1152,17 @@ func migration5ReleaseGroupCompositeUnique( ) } - // Copy all data. + // Copy all data. Columns are listed explicitly so later schema + // additions (e.g. migration 13's mbid column) don't break this + // migration when it runs on a fresh DB where CREATE TABLE IF NOT + // EXISTS has already materialized the current schema. if _, err := db.ExecContext(ctx, ` INSERT INTO release_groups_new - SELECT * FROM release_groups + (id, name, cover_art_id, album_artist_credit_id, + year, total_tracks, total_discs) + SELECT id, name, cover_art_id, album_artist_credit_id, + year, total_tracks, total_discs + FROM release_groups `); err != nil { return fmt.Errorf( "migration 5: could not copy data: %w", err, @@ -1358,6 +1931,536 @@ func migration10PlayHistory( return nil } +// migration11ExploreCache creates the explore_cache table for +// MusicBrainz and ListenBrainz API response caching. The table +// stores raw JSON keyed by URL with TTL-based expiry and optional +// MBID columns for future autotagging lookups. +func migration11ExploreCache( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 11: explore_cache table", + ) + + if _, err := db.ExecContext(ctx, ` + 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 + ) + `); err != nil { + return fmt.Errorf( + "migration 11: could not create explore_cache table: %w", + err, + ) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_explore_cache_expires + ON explore_cache(expires_at) + `); err != nil { + return fmt.Errorf( + "migration 11: could not create expires index: %w", + err, + ) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_explore_cache_mbid + ON explore_cache(mbid) + `); err != nil { + return fmt.Errorf( + "migration 11: could not create mbid index: %w", + err, + ) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 11", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 11: %w", err, + ) + } + + logger.Info("migration 11 complete") + + return nil +} + +// migration12ExploreSearchIndex creates the explore_index table, +// the FTS5 virtual table for full-text search, sync triggers, and +// the explore_index_meta table for build tracking. +func migration12ExploreSearchIndex( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 12: explore search index") + + // Content table — slim denormalized rows for search. + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS 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, + popularity INTEGER NOT NULL DEFAULT 0, + extra_json TEXT + ) + `); err != nil { + return fmt.Errorf("migration 12: create explore_index: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_explore_index_mbid + ON explore_index(entity_type, mbid) + `); err != nil { + return fmt.Errorf("migration 12: create mbid index: %w", err) + } + + // FTS5 virtual table backed by the content table. + if _, err := db.ExecContext(ctx, ` + CREATE VIRTUAL TABLE IF NOT EXISTS explore_index_fts USING fts5( + title, artist_name, + content='explore_index', + content_rowid='id' + ) + `); err != nil { + return fmt.Errorf("migration 12: create FTS5 table: %w", err) + } + + // Triggers to keep FTS in sync. + if _, err := db.ExecContext(ctx, ` + CREATE TRIGGER IF NOT EXISTS explore_index_ai AFTER INSERT ON explore_index BEGIN + INSERT INTO explore_index_fts(rowid, title, artist_name) + VALUES (new.id, new.title, new.artist_name); + END + `); err != nil { + return fmt.Errorf("migration 12: create insert trigger: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE TRIGGER IF NOT EXISTS explore_index_ad AFTER DELETE ON explore_index BEGIN + INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name) + VALUES ('delete', old.id, old.title, old.artist_name); + END + `); err != nil { + return fmt.Errorf("migration 12: create delete trigger: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE TRIGGER IF NOT EXISTS explore_index_au AFTER UPDATE ON explore_index BEGIN + INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name) + VALUES ('delete', old.id, old.title, old.artist_name); + INSERT INTO explore_index_fts(rowid, title, artist_name) + VALUES (new.id, new.title, new.artist_name); + END + `); err != nil { + return fmt.Errorf("migration 12: create update trigger: %w", err) + } + + // Metadata table for build tracking. + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS explore_index_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); err != nil { + return fmt.Errorf("migration 12: create meta table: %w", err) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 12", + ); err != nil { + return fmt.Errorf("could not set user_version to 12: %w", err) + } + + logger.Info("migration 12 complete") + + return nil +} + +// migration13MBIDColumns adds MusicBrainz ID columns to artists, +// release_groups, and recordings for linking local library entities +// to MusicBrainz/ListenBrainz explore data. +func migration13MBIDColumns( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 13: MusicBrainz ID columns") + + alterStmts := []struct { + table string + column string + }{ + {"artists", "mbid"}, + {"release_groups", "mbid"}, + {"recordings", "mbid"}, + } + + for _, s := range alterStmts { + stmt := fmt.Sprintf( + "ALTER TABLE %s ADD COLUMN %s TEXT", s.table, s.column, + ) + + if _, err := db.ExecContext(ctx, stmt); err != nil { + // Column may already exist from a partial migration. + if !strings.Contains(err.Error(), "duplicate column") { + return fmt.Errorf("migration 13: alter %s: %w", s.table, err) + } + } + } + + // Partial indexes for MBID lookups (only index non-NULL rows). + indexes := []string{ + "CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL", + "CREATE INDEX IF NOT EXISTS idx_release_groups_mbid ON release_groups(mbid) WHERE mbid IS NOT NULL", + "CREATE INDEX IF NOT EXISTS idx_recordings_mbid ON recordings(mbid) WHERE mbid IS NOT NULL", + } + + for _, stmt := range indexes { + if _, err := db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("migration 13: create index: %w", err) + } + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 13", + ); err != nil { + return fmt.Errorf("could not set user_version to 13: %w", err) + } + + logger.Info("migration 13 complete") + + return nil +} + +// migration14ExploreAliases adds an aliases column to explore_index +// and rebuilds the FTS5 virtual table with three searchable columns +// (title, artist_name, aliases) for alias-aware search. +func migration14ExploreAliases( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 14: explore index aliases + FTS5 rebuild") + + // Add aliases column to content table. + if _, err := db.ExecContext(ctx, + "ALTER TABLE explore_index ADD COLUMN aliases TEXT DEFAULT ''", + ); err != nil { + if !strings.Contains(err.Error(), "duplicate column") { + return fmt.Errorf("migration 14: alter explore_index: %w", err) + } + } + + // Drop old triggers. + for _, name := range []string{ + "explore_index_ai", "explore_index_ad", "explore_index_au", + } { + if _, err := db.ExecContext(ctx, + "DROP TRIGGER IF EXISTS "+name, + ); err != nil { + return fmt.Errorf("migration 14: drop trigger %s: %w", name, err) + } + } + + // Drop and recreate FTS5 with 3 columns. + if _, err := db.ExecContext(ctx, + "DROP TABLE IF EXISTS explore_index_fts", + ); err != nil { + return fmt.Errorf("migration 14: drop FTS5: %w", err) + } + + 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 14: create FTS5: %w", err) + } + + // Recreate triggers with 3 columns. + 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 14: create insert 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 14: create delete 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 14: create update trigger: %w", err) + } + + // Rebuild FTS5 index from existing content table rows. + if _, err := db.ExecContext(ctx, + "INSERT INTO explore_index_fts(explore_index_fts) VALUES ('rebuild')", + ); err != nil { + return fmt.Errorf("migration 14: rebuild FTS5: %w", err) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 14", + ); err != nil { + return fmt.Errorf("could not set user_version to 14: %w", err) + } + + // Clear the index build timestamp so the next build populates aliases. + _, _ = db.ExecContext(ctx, + "DELETE FROM explore_index_meta WHERE key IN ('tier1_built', 'discog_built')", + ) + + logger.Info("migration 14 complete") + + return nil +} + +// migration15PersonalizationColumns adds in_library and is_similar +// columns to explore_index for personalized search ranking. +func migration15PersonalizationColumns( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 15: personalization columns") + + for _, col := range []string{"in_library", "is_similar"} { + stmt := fmt.Sprintf( + "ALTER TABLE explore_index ADD COLUMN %s INTEGER NOT NULL DEFAULT 0", col, + ) + + if _, err := db.ExecContext(ctx, stmt); err != nil { + if !strings.Contains(err.Error(), "duplicate column") { + return fmt.Errorf("migration 15: alter explore_index: %w", err) + } + } + } + + // Backfill in_library for artists already in the library. + if _, err := db.ExecContext(ctx, ` + UPDATE explore_index SET in_library = 1 + WHERE entity_type = 'artist' + AND mbid IN (SELECT mbid FROM artists WHERE mbid IS NOT NULL AND mbid != '') + `); err != nil { + logger.Warn("migration 15: backfill in_library artists", "error", err) + } + + // Backfill in_library for release groups already in the library. + if _, err := db.ExecContext(ctx, ` + UPDATE explore_index SET in_library = 1 + WHERE entity_type = 'release_group' + AND mbid IN (SELECT mbid FROM release_groups WHERE mbid IS NOT NULL AND mbid != '') + `); err != nil { + logger.Warn("migration 15: backfill in_library release_groups", "error", err) + } + + // Clear discog_built so the next index build populates these flags. + _, _ = db.ExecContext(ctx, + "DELETE FROM explore_index_meta WHERE key = 'discog_built'", + ) + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 15", + ); err != nil { + return fmt.Errorf("could not set user_version to 15: %w", err) + } + + logger.Info("migration 15 complete") + + return nil +} + +// migration16ArtistImages creates the artist_images table for +// storing multiple artist photos from multiple sources, with +// thumbnail generation for the primary image. +func migration16ArtistImages( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 16: artist_images table") + + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS artist_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_mbid TEXT NOT NULL, + source TEXT NOT NULL, + source_url TEXT NOT NULL, + file_path TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + width INTEGER, + height INTEGER, + file_size INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + return fmt.Errorf("migration 16: create artist_images: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_artist_images_mbid + ON artist_images(artist_mbid) + `); err != nil { + return fmt.Errorf("migration 16: create mbid index: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source + ON artist_images(artist_mbid, source, source_url) + `); err != nil { + return fmt.Errorf("migration 16: create source index: %w", err) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 16", + ); err != nil { + return fmt.Errorf("could not set user_version to 16: %w", err) + } + + logger.Info("migration 16 complete") + + return nil +} + +func migration17SimilarArtistMap( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 17: similar_artist_map table") + + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS similar_artist_map ( + source_artist_mbid TEXT NOT NULL, + similar_artist_mbid TEXT NOT NULL, + similar_artist_name TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (source_artist_mbid, similar_artist_mbid) + ) + `); err != nil { + return fmt.Errorf("migration 17: create similar_artist_map: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source + ON similar_artist_map(source_artist_mbid) + `); err != nil { + return fmt.Errorf("migration 17: create source index: %w", err) + } + + if _, err := db.ExecContext(ctx, + "PRAGMA user_version = 17", + ); err != nil { + return fmt.Errorf("could not set user_version to 17: %w", err) + } + + logger.Info("migration 17 complete") + + 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 { @@ -1480,3 +2583,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/database_test.go b/backend/database/database_test.go index cd5ef54..96eb4ab 100644 --- a/backend/database/database_test.go +++ b/backend/database/database_test.go @@ -1053,3 +1053,200 @@ func TestMigration10PlayHistory(t *testing.T) { t.Errorf("track_metadata play_count = %d, want 1", viewPlayCount) } } + +// --------------------------------------------------------------------------- +// Migration 11 — explore_cache table +// --------------------------------------------------------------------------- + +func TestMigration11ExploreCache(t *testing.T) { + t.Parallel() + + // explore_cache was split into http_cache + artist_metadata by + // migration 27 and is dropped on fresh installs. This test covers + // a table that no longer exists in a fresh DB; revisit once the + // explore cache tests are rewritten against the new schemas. + t.Skip("explore_cache dropped by migration 27; test is obsolete") + + db := NewTestDB(t) + + // Verify user_version >= 11. + var version int + + verRows, err := db.QueryContext("PRAGMA user_version") + if err != nil { + t.Fatalf("PRAGMA user_version: %v", err) + } + + if !verRows.Next() { + _ = verRows.Close() + t.Fatal("PRAGMA user_version: no row returned") + } + + if err := verRows.Scan(&version); err != nil { + _ = verRows.Close() + t.Fatalf("scan user_version: %v", err) + } + + _ = verRows.Close() + + if version < 11 { + t.Errorf("user_version = %d, want >= 11", version) + } + + // Verify explore_cache table exists. + var tableCount int64 + + tblRows, err := db.QueryContext( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='explore_cache'", + ) + if err != nil { + t.Fatalf("query sqlite_master: %v", err) + } + + if !tblRows.Next() { + _ = tblRows.Close() + + t.Fatal("no row from sqlite_master query") + } + + if err := tblRows.Scan(&tableCount); err != nil { + _ = tblRows.Close() + t.Fatalf("scan table count: %v", err) + } + + _ = tblRows.Close() + + if tableCount != 1 { + t.Errorf("explore_cache table count = %d, want 1", tableCount) + } + + // Verify all expected columns exist. + expectedCols := map[string]bool{ + "url_key": false, + "response": false, + "mbid": false, + "entity_type": false, + "expires_at": false, + "created_at": false, + } + + colRows, err := db.QueryContext( + "PRAGMA table_info(explore_cache)", + ) + if err != nil { + t.Fatalf("PRAGMA table_info(explore_cache): %v", err) + } + + for colRows.Next() { + var ( + cid int64 + name string + colType string + notNull int64 + dfltValue sql.NullString + pk int64 + ) + + if err := colRows.Scan( + &cid, &name, &colType, ¬Null, &dfltValue, &pk, + ); err != nil { + _ = colRows.Close() + t.Fatalf("scan table_info row: %v", err) + } + + if _, ok := expectedCols[name]; ok { + expectedCols[name] = true + } + } + + _ = colRows.Close() + + for col, found := range expectedCols { + if !found { + t.Errorf("explore_cache missing column: %s", col) + } + } + + // Verify indexes exist. + idxRows, err := db.QueryContext( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='explore_cache'", + ) + if err != nil { + t.Fatalf("query indexes: %v", err) + } + + indexes := map[string]bool{} + + for idxRows.Next() { + var name string + + if err := idxRows.Scan(&name); err != nil { + _ = idxRows.Close() + t.Fatalf("scan index name: %v", err) + } + + indexes[name] = true + } + + _ = idxRows.Close() + + if !indexes["idx_explore_cache_expires"] { + t.Error("missing index: idx_explore_cache_expires") + } + + if !indexes["idx_explore_cache_mbid"] { + t.Error("missing index: idx_explore_cache_mbid") + } + + // Round-trip: insert and read back. + _, err = db.ExecContext( + `INSERT INTO explore_cache (url_key, response, mbid, entity_type, expires_at) + VALUES ('test-key', '{"data":"value"}', 'abc-123', 'artist', datetime('now', '+1 hour'))`, + ) + if err != nil { + t.Fatalf("insert explore_cache: %v", err) + } + + rows, err := db.QueryContext( + "SELECT url_key, response, mbid, entity_type FROM explore_cache WHERE url_key = 'test-key'", + ) + if err != nil { + t.Fatalf("query explore_cache: %v", err) + } + + if !rows.Next() { + _ = rows.Close() + t.Fatal("explore_cache row not found") + } + + var ( + urlKey string + response string + mbid sql.NullString + entityType sql.NullString + ) + + if err := rows.Scan(&urlKey, &response, &mbid, &entityType); err != nil { + _ = rows.Close() + + t.Fatalf("scan explore_cache row: %v", err) + } + + _ = rows.Close() + + if urlKey != "test-key" { + t.Errorf("url_key = %q, want %q", urlKey, "test-key") + } + + if response != `{"data":"value"}` { + t.Errorf("response = %q, want %q", response, `{"data":"value"}`) + } + + if !mbid.Valid || mbid.String != "abc-123" { + t.Errorf("mbid = %v, want abc-123", mbid) + } + + if !entityType.Valid || entityType.String != "artist" { + t.Errorf("entity_type = %v, want artist", entityType) + } +} diff --git a/backend/database/sql/queries/artists.sql b/backend/database/sql/queries/artists.sql index caf7af0..942b910 100644 --- a/backend/database/sql/queries/artists.sql +++ b/backend/database/sql/queries/artists.sql @@ -32,7 +32,7 @@ SELECT * FROM artists ORDER BY name; -- name: GetAlbumArtists :many -SELECT DISTINCT a.id, a.name +SELECT DISTINCT a.id, a.name, a.mbid FROM artists a JOIN artist_credit_artist aca ON aca.artist_id = a.id JOIN artist_credit ac ON ac.id = aca.credit_id @@ -40,7 +40,7 @@ JOIN release_groups rg ON rg.album_artist_credit_id = ac.id ORDER BY a.name; -- name: GetAlbumArtistsByLibrary :many -SELECT DISTINCT a.id, a.name +SELECT DISTINCT a.id, a.name, a.mbid FROM artists a JOIN artist_credit_artist aca ON aca.artist_id = a.id JOIN artist_credit ac ON ac.id = aca.credit_id 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/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql index 4c0c059..7110dd0 100644 --- a/backend/database/sql/queries/release_groups.sql +++ b/backend/database/sql/queries/release_groups.sql @@ -50,6 +50,7 @@ SELECT rg.id, rg.name, rg.year, + rg.mbid, COALESCE(ac.text, fallback_ac.text, '') as artist_name, COALESCE(ca.file_path, '') as cover_art_path FROM release_groups rg @@ -69,6 +70,7 @@ SELECT rg.id, rg.name, rg.year, + rg.mbid, COALESCE(ac.text, fallback_ac.text, '') as artist_name, COALESCE(ca.file_path, '') as cover_art_path FROM release_groups rg 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/artists.sql b/backend/database/sql/schemas/artists.sql index 93bcb09..09d2cf7 100644 --- a/backend/database/sql/schemas/artists.sql +++ b/backend/database/sql/schemas/artists.sql @@ -1,4 +1,5 @@ CREATE TABLE IF NOT EXISTS artists ( id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE + name TEXT NOT NULL UNIQUE, + mbid TEXT ); 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/release_groups.sql b/backend/database/sql/schemas/release_groups.sql index 78f0e8e..f2339ba 100644 --- a/backend/database/sql/schemas/release_groups.sql +++ b/backend/database/sql/schemas/release_groups.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS release_groups ( year INTEGER, total_tracks INTEGER, total_discs INTEGER, + mbid TEXT, FOREIGN KEY(cover_art_id) REFERENCES cover_art(id), FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id), UNIQUE(name, album_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/artists.sql.go b/backend/database/sql/sqlcgen/artists.sql.go index b1d563f..e71f0d1 100644 --- a/backend/database/sql/sqlcgen/artists.sql.go +++ b/backend/database/sql/sqlcgen/artists.sql.go @@ -11,13 +11,13 @@ import ( const createArtist = `-- name: CreateArtist :one INSERT INTO artists (name) VALUES (?) -RETURNING id, name +RETURNING id, name, mbid ` func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error) { row := q.db.QueryRowContext(ctx, createArtist, name) var i Artist - err := row.Scan(&i.ID, &i.Name) + err := row.Scan(&i.ID, &i.Name, &i.Mbid) return i, err } @@ -41,7 +41,7 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error { } const getAlbumArtists = `-- name: GetAlbumArtists :many -SELECT DISTINCT a.id, a.name +SELECT DISTINCT a.id, a.name, a.mbid FROM artists a JOIN artist_credit_artist aca ON aca.artist_id = a.id JOIN artist_credit ac ON ac.id = aca.credit_id @@ -58,7 +58,7 @@ func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) { var items []Artist for rows.Next() { var i Artist - if err := rows.Scan(&i.ID, &i.Name); err != nil { + if err := rows.Scan(&i.ID, &i.Name, &i.Mbid); err != nil { return nil, err } items = append(items, i) @@ -73,7 +73,7 @@ func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) { } const getAlbumArtistsByLibrary = `-- name: GetAlbumArtistsByLibrary :many -SELECT DISTINCT a.id, a.name +SELECT DISTINCT a.id, a.name, a.mbid FROM artists a JOIN artist_credit_artist aca ON aca.artist_id = a.id JOIN artist_credit ac ON ac.id = aca.credit_id @@ -100,7 +100,7 @@ func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) var items []Artist for rows.Next() { var i Artist - if err := rows.Scan(&i.ID, &i.Name); err != nil { + if err := rows.Scan(&i.ID, &i.Name, &i.Mbid); err != nil { return nil, err } items = append(items, i) @@ -115,7 +115,7 @@ func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) } const getAllArtists = `-- name: GetAllArtists :many -SELECT id, name FROM artists +SELECT id, name, mbid FROM artists ORDER BY name ` @@ -128,7 +128,7 @@ func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) { var items []Artist for rows.Next() { var i Artist - if err := rows.Scan(&i.ID, &i.Name); err != nil { + if err := rows.Scan(&i.ID, &i.Name, &i.Mbid); err != nil { return nil, err } items = append(items, i) @@ -143,26 +143,26 @@ func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) { } const getArtist = `-- name: GetArtist :one -SELECT id, name FROM artists +SELECT id, name, mbid FROM artists WHERE id = ? LIMIT 1 ` func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) { row := q.db.QueryRowContext(ctx, getArtist, id) var i Artist - err := row.Scan(&i.ID, &i.Name) + err := row.Scan(&i.ID, &i.Name, &i.Mbid) return i, err } const getArtistByName = `-- name: GetArtistByName :one -SELECT id, name FROM artists +SELECT id, name, mbid FROM artists WHERE name = ? LIMIT 1 ` func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) { row := q.db.QueryRowContext(ctx, getArtistByName, name) var i Artist - err := row.Scan(&i.ID, &i.Name) + err := row.Scan(&i.ID, &i.Name, &i.Mbid) return i, err } @@ -185,12 +185,12 @@ func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) erro const upsertArtist = `-- name: UpsertArtist :one INSERT INTO artists (name) VALUES (?) ON CONFLICT(name) DO UPDATE SET name = excluded.name -RETURNING id, name +RETURNING id, name, mbid ` func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) { row := q.db.QueryRowContext(ctx, upsertArtist, name) var i Artist - err := row.Scan(&i.ID, &i.Name) + err := row.Scan(&i.ID, &i.Name, &i.Mbid) return i, err } 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 e860e49..a722561 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -12,6 +12,7 @@ import ( type Artist struct { ID int64 Name string + Mbid sql.NullString } type ArtistCredit struct { @@ -25,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 @@ -59,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 @@ -129,6 +145,7 @@ type Recording struct { Composer sql.NullString Lyrics sql.NullString Comment sql.NullString + Mbid sql.NullString } type RecordingGenre struct { @@ -145,6 +162,7 @@ type ReleaseGroup struct { Year sql.NullInt64 TotalTracks sql.NullInt64 TotalDiscs sql.NullInt64 + Mbid sql.NullString } type ReleaseGroupRecording struct { @@ -183,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/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go index df1c187..362353b 100644 --- a/backend/database/sql/sqlcgen/release_groups.sql.go +++ b/backend/database/sql/sqlcgen/release_groups.sql.go @@ -23,7 +23,7 @@ func (q *Queries) CountReleaseGroupRecordings(ctx context.Context, releaseGroupI const createReleaseGroup = `-- name: CreateReleaseGroup :one INSERT INTO release_groups (name) VALUES (?) -RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs +RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid ` func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) { @@ -37,6 +37,7 @@ func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseG &i.Year, &i.TotalTracks, &i.TotalDiscs, + &i.Mbid, ) return i, err } @@ -45,7 +46,7 @@ const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one INSERT INTO release_groups ( name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs ) VALUES (?, ?, ?, ?, ?, ?) -RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs +RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid ` type CreateReleaseGroupFullParams struct { @@ -75,6 +76,7 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG &i.Year, &i.TotalTracks, &i.TotalDiscs, + &i.Mbid, ) return i, err } @@ -233,6 +235,7 @@ SELECT rg.id, rg.name, rg.year, + rg.mbid, COALESCE(ac.text, fallback_ac.text, '') as artist_name, COALESCE(ca.file_path, '') as cover_art_path FROM release_groups rg @@ -252,6 +255,7 @@ type GetAllAlbumsWithDetailsRow struct { ID int64 Name string Year sql.NullInt64 + Mbid sql.NullString ArtistName string CoverArtPath string } @@ -269,6 +273,7 @@ func (q *Queries) GetAllAlbumsWithDetails(ctx context.Context) ([]GetAllAlbumsWi &i.ID, &i.Name, &i.Year, + &i.Mbid, &i.ArtistName, &i.CoverArtPath, ); err != nil { @@ -290,6 +295,7 @@ SELECT rg.id, rg.name, rg.year, + rg.mbid, COALESCE(ac.text, fallback_ac.text, '') as artist_name, COALESCE(ca.file_path, '') as cover_art_path FROM release_groups rg @@ -316,6 +322,7 @@ type GetAllAlbumsWithDetailsByLibraryRow struct { ID int64 Name string Year sql.NullInt64 + Mbid sql.NullString ArtistName string CoverArtPath string } @@ -333,6 +340,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI &i.ID, &i.Name, &i.Year, + &i.Mbid, &i.ArtistName, &i.CoverArtPath, ); err != nil { @@ -350,7 +358,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI } const getAllReleaseGroups = `-- name: GetAllReleaseGroups :many -SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups +SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups ORDER BY name ` @@ -371,6 +379,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro &i.Year, &i.TotalTracks, &i.TotalDiscs, + &i.Mbid, ); err != nil { return nil, err } @@ -386,7 +395,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro } const getReleaseGroup = `-- name: GetReleaseGroup :one -SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups +SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups WHERE id = ? LIMIT 1 ` @@ -401,12 +410,13 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup, &i.Year, &i.TotalTracks, &i.TotalDiscs, + &i.Mbid, ) return i, err } const getReleaseGroupByNameAndArtist = `-- name: GetReleaseGroupByNameAndArtist :one -SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups +SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups WHERE name = ? AND album_artist_credit_id = ? LIMIT 1 ` @@ -426,6 +436,7 @@ func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetRel &i.Year, &i.TotalTracks, &i.TotalDiscs, + &i.Mbid, ) return i, err } @@ -468,7 +479,7 @@ VALUES (?, ?, ?) ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id), year = COALESCE(excluded.year, release_groups.year) -RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs +RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid ` type UpsertReleaseGroupParams struct { @@ -488,6 +499,7 @@ func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroup &i.Year, &i.TotalTracks, &i.TotalDiscs, + &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 new file mode 100644 index 0000000..2404a6e --- /dev/null +++ b/backend/explore/artistimage.go @@ -0,0 +1,962 @@ +package explore + +import ( + "context" + "crypto/md5" //nolint:gosec // MD5 for Wikimedia URL hashing + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "image" + "image/jpeg" + _ "image/png" // register PNG decoder + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/image/draw" + + "yellowjacket/backend/database" + "yellowjacket/backend/system" +) + +// ErrArtistImage is returned when an artist image HTTP fetch fails. +var ErrArtistImage = errors.New("artist image fetch failed") + +const ( + wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb" + wikidataAPIBase = "https://www.wikidata.org/w/api.php" + wikipediaAPIBase = "https://en.wikipedia.org/w/api.php" + fanartTVAPIBase = "https://webservice.fanart.tv/v3/music" + audioDBAPIBase = "https://www.theaudiodb.com/api/v1/json/2" + artistImageTimeout = 10 * time.Second + artistImageCacheTTL = 365 * 24 * time.Hour // positive results: ~permanent + artistImageMissCacheTTL = 30 * 24 * time.Hour // negative results: retry monthly + artistImageBaseDir = "artist-images" + artistImageMaxBytes = 2 * 1024 * 1024 + artistImageMaxSize = 500 // max dimension for stored full-res images + maxImagesPerArtist = 10 +) + +// fanartTVProjectKey is the project API key for fanart.tv. +// Set via -ldflags at build time, or FANART_TV_API_KEY env var. +// Users can provide their own personal key via FANART_TV_PERSONAL_KEY. +// Per fanart.tv terms: images are CC-BY-SA, attribution required. +// +//nolint:gochecknoglobals +var fanartTVProjectKey = "" + +// artistImageTier defines a thumbnail size variant. +type artistImageTier struct { + Suffix string + MaxSize int + Quality int +} + +var artistImageTiers = []artistImageTier{ + {Suffix: "_sm", MaxSize: 100, Quality: 75}, + {Suffix: "_md", MaxSize: 200, Quality: 80}, + {Suffix: "_lg", MaxSize: 400, Quality: 85}, +} + +// ArtistImageProvider resolves, fetches, and caches artist images +// from multiple sources. Stores up to 10 images per artist with +// sm/md/lg thumbnails for the primary image. +type ArtistImageProvider struct { + db *database.DB + cache *Cache + mbLimiter *RateLimiter + client *http.Client + logger *slog.Logger + baseDir string + fanartAPIKey string // resolved project key + optional personal key +} + +// NewArtistImageProvider creates a multi-source artist image provider. +func NewArtistImageProvider( + db *database.DB, + cache *Cache, + mbLimiter *RateLimiter, + logger *slog.Logger, +) *ArtistImageProvider { + dir := "" + + dataDir, err := system.GetUserDataDirPath() + if err == nil { + dir = filepath.Join(dataDir, artistImageBaseDir) + _ = os.MkdirAll(dir, 0o755) + } + + // Resolve fanart.tv API key: env var > build-time ldflags. + fanartKey := os.Getenv("FANART_TV_API_KEY") + if fanartKey == "" { + fanartKey = fanartTVProjectKey + } + + if fanartKey != "" { + logger.Info("fanart.tv API key configured") + } + + return &ArtistImageProvider{ + db: db, + cache: cache, + mbLimiter: mbLimiter, + client: &http.Client{Timeout: artistImageTimeout}, + logger: logger, + baseDir: dir, + fanartAPIKey: fanartKey, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// GetArtistImage returns the primary image as a base64 data URL. +// Resolves from all sources if not yet cached. +func (p *ArtistImageProvider) GetArtistImage(artistMBID string) string { + if artistMBID == "" || p.baseDir == "" { + return "" + } + + // Check for existing primary image on disk. + primaryPath := p.primaryPath(artistMBID) + if data := readFileData(primaryPath); data != "" { + return data + } + + // Check if we already know there's no image. + if p.isMiss(artistMBID) { + return "" + } + + // Resolve from all sources and select primary. + p.resolveAllSources(artistMBID) + + // Try again after resolution. + if data := readFileData(primaryPath); data != "" { + return data + } + + // Mark as miss. + p.writeMiss(artistMBID) + + return "" +} + +// GetCachedImage returns the primary image from disk cache only. +// No network fetches. +func (p *ArtistImageProvider) GetCachedImage(artistMBID string) string { + if artistMBID == "" || p.baseDir == "" { + return "" + } + + return readFileData(p.primaryPath(artistMBID)) +} + +// GetImageURLs returns the asset-handler URLs for the primary image +// at all size tiers. Returns empty strings if no image. +func (p *ArtistImageProvider) GetImageURLs(artistMBID string) (string, string, string, string) { + if artistMBID == "" || p.baseDir == "" { + return "", "", "", "" + } + + dir := p.artistDir(artistMBID) + prefix := "/artist-images/" + artistMBID[:2] + "/" + artistMBID + "/" + + if _, err := os.Stat(filepath.Join(dir, "primary.jpg")); err != nil { + return "", "", "", "" + } + + var small, medium, large string + + full := prefix + "primary.jpg" + + for _, tier := range artistImageTiers { + path := filepath.Join(dir, "primary"+tier.Suffix+".jpg") + if _, err := os.Stat(path); err == nil { + url := prefix + "primary" + tier.Suffix + ".jpg" + + switch tier.Suffix { + case "_sm": + small = url + case "_md": + medium = url + case "_lg": + large = url + } + } + } + + return small, medium, large, full +} + +// GetAliases returns artist aliases from cached MB rels. +func (p *ArtistImageProvider) GetAliases(artistMBID string) string { + cacheKey := "mb:artist-rels:" + artistMBID + + data, ok := p.cache.Get(cacheKey) + if !ok { + return "" + } + + var envelope struct { + Aliases []struct { + Name string `json:"name"` + } `json:"aliases"` + } + + if err := json.Unmarshal(data, &envelope); err != nil || len(envelope.Aliases) == 0 { + return "" + } + + names := make([]string, 0, len(envelope.Aliases)) + + for _, a := range envelope.Aliases { + if a.Name != "" { + names = append(names, a.Name) + } + } + + 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 +// --------------------------------------------------------------------------- + +type mbRelation struct { + Type string `json:"type"` + URL struct { + Resource string `json:"resource"` + } `json:"url"` +} + +func (p *ArtistImageProvider) resolveAllSources(artistMBID string) { + type imageSource struct { + source string + url string + } + + var urls []imageSource + + // Source 0 (highest priority): fanart.tv artist thumbnails. + if p.fanartAPIKey != "" { + fanartURLs := p.fetchFanartTV(artistMBID) + + for _, u := range fanartURLs { + urls = append(urls, imageSource{source: "fanart", url: u}) + } + } + + // Source 1: TheAudioDB artist thumb. + if audioDBURLs := p.fetchAudioDB(artistMBID); len(audioDBURLs) > 0 { + for _, u := range audioDBURLs { + urls = append(urls, imageSource{source: "audiodb", url: u}) + } + } + + rels := p.fetchMBRels(artistMBID) + + // Source 2: MB direct image relations (Wikimedia Commons). + for _, rel := range rels { + if rel.Type != "image" { + continue + } + + resource := rel.URL.Resource + + if idx := strings.LastIndex(resource, "File:"); idx >= 0 { + filename := resource[idx+5:] + thumbURL := wikimediaThumbURL(filename) + + if thumbURL != "" { + urls = append(urls, imageSource{source: "wikimedia", url: thumbURL}) + } + } + } + + // Source 2: Wikidata P18. + qid := p.getWikidataQID(rels) + if qid != "" { + if thumbURL := p.fetchWikidataP18(qid); thumbURL != "" { + // Avoid duplicates with source 1. + dup := false + + for _, u := range urls { + if u.url == thumbURL { + dup = true + + break + } + } + + if !dup { + urls = append(urls, imageSource{"wikidata", thumbURL}) + } + } + + // Source 3: Wikipedia lead image. + if leadURL := p.fetchWikipediaLeadImage(qid); leadURL != "" { + dup := false + + for _, u := range urls { + if u.url == leadURL { + dup = true + + break + } + } + + if !dup { + urls = append(urls, imageSource{"wikipedia", leadURL}) + } + } + } + + if len(urls) == 0 { + return + } + + // Cap at maxImagesPerArtist. + if len(urls) > maxImagesPerArtist { + urls = urls[:maxImagesPerArtist] + } + + // Fetch and store each image. + dir := p.artistDir(artistMBID) + _ = os.MkdirAll(dir, 0o755) + + for i, u := range urls { + imgData, err := p.fetchImageBytes(u.url) + if err != nil || len(imgData) == 0 { + continue + } + + filename := fmt.Sprintf("%s_%d.jpg", u.source, i) + path := filepath.Join(dir, filename) + _ = os.WriteFile(path, imgData, 0o644) + + // Store in DB. + isPrimary := 0 + if i == 0 { + isPrimary = 1 + } + + _, _ = p.db.ExecContext(` + INSERT OR REPLACE INTO artist_images + (artist_mbid, source, source_url, file_path, is_primary, sort_order, file_size) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, artistMBID, u.source, u.url, path, isPrimary, i, len(imgData)) + + // Generate thumbnails for the primary image. + if i == 0 { + p.setPrimary(artistMBID, dir, imgData) + } + } +} + +// setPrimary copies image data to primary.jpg and generates thumbnails. +func (p *ArtistImageProvider) setPrimary(artistMBID, dir string, imgData []byte) { + primaryPath := filepath.Join(dir, "primary.jpg") + _ = os.WriteFile(primaryPath, imgData, 0o644) + + // Decode and generate thumbnails. + img, _, err := image.Decode(strings.NewReader(string(imgData))) + if err != nil { + // Try as bytes reader. + reader := strings.NewReader(string(imgData)) + + img, _, err = image.Decode(reader) + if err != nil { + p.logger.Debug("artist image: could not decode for thumbnails", + "mbid", artistMBID, "error", err) + + return + } + } + + for _, tier := range artistImageTiers { + thumbPath := filepath.Join(dir, "primary"+tier.Suffix+".jpg") + p.generateThumbnail(img, thumbPath, tier.MaxSize, tier.Quality) + } +} + +func (p *ArtistImageProvider) generateThumbnail( + src image.Image, path string, maxSize, quality int, +) { + bounds := src.Bounds() + w := bounds.Dx() + h := bounds.Dy() + + if w <= maxSize && h <= maxSize { + // Image already small enough — just encode as JPEG. + f, err := os.Create(path) + if err != nil { + return + } + + defer func() { _ = f.Close() }() + + _ = jpeg.Encode(f, src, &jpeg.Options{Quality: quality}) + + return + } + + // Scale down maintaining aspect ratio. + var newW, newH int + if w > h { + newW = maxSize + newH = maxSize * h / w + } else { + newH = maxSize + newW = maxSize * w / h + } + + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil) + + f, err := os.Create(path) + if err != nil { + return + } + + defer func() { _ = f.Close() }() + + _ = jpeg.Encode(f, dst, &jpeg.Options{Quality: quality}) +} + +// --------------------------------------------------------------------------- +// MB rels + Wikidata + Wikipedia +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Source 0: fanart.tv +// --------------------------------------------------------------------------- + +// fetchFanartTV returns artist thumbnail URLs from fanart.tv. +// Uses the project API key + optional user personal key. +// Returns up to 5 URLs (artistthumb images, sorted by likes). +func (p *ArtistImageProvider) fetchFanartTV(artistMBID string) []string { + cacheKey := "fanart:" + artistMBID + + if data, ok := p.cache.Get(cacheKey); ok { + var cached []string + if err := json.Unmarshal(data, &cached); err == nil { + return cached + } + } + + url := fmt.Sprintf("%s/%s?api_key=%s", fanartTVAPIBase, artistMBID, p.fanartAPIKey) + + // Add personal key if the user configured one. + if personalKey := os.Getenv("FANART_TV_PERSONAL_KEY"); personalKey != "" { + url += "&client_key=" + personalKey + } + + body, err := p.fetchURL(url) + if err != nil { + // Cache empty result to avoid re-fetching. + p.cache.Set(cacheKey, []byte("[]"), artistImageMissCacheTTL, artistMBID, "artist") + + return nil + } + + var response struct { + ArtistThumb []struct { + URL string `json:"url"` + Likes string `json:"likes"` + } `json:"artistthumb"` + } + + if err := json.Unmarshal(body, &response); err != nil || len(response.ArtistThumb) == 0 { + p.cache.Set(cacheKey, []byte("[]"), artistImageMissCacheTTL, artistMBID, "artist") + + return nil + } + + // Take up to 5 thumbs (they're already sorted by likes on the API side). + limit := 5 + if limit > len(response.ArtistThumb) { + limit = len(response.ArtistThumb) + } + + urls := make([]string, limit) + for i := range limit { + urls[i] = response.ArtistThumb[i].URL + } + + // Cache the resolved URLs. + data, _ := json.Marshal(urls) + p.cache.Set(cacheKey, data, artistImageCacheTTL, artistMBID, "artist") + + return urls +} + +// --------------------------------------------------------------------------- +// Source 1: TheAudioDB +// --------------------------------------------------------------------------- + +// fetchAudioDB returns artist thumb URLs from TheAudioDB. +// Uses the free API key (2) for MBID-based lookups. +func (p *ArtistImageProvider) fetchAudioDB(artistMBID string) []string { + cacheKey := "audiodb:" + artistMBID + + if data, ok := p.cache.Get(cacheKey); ok { + var cached []string + if err := json.Unmarshal(data, &cached); err == nil { + return cached + } + } + + url := fmt.Sprintf("%s/artist-mb.php?i=%s", audioDBAPIBase, artistMBID) + + body, err := p.fetchURL(url) + if err != nil { + p.cache.Set(cacheKey, []byte("[]"), artistImageMissCacheTTL, artistMBID, "artist") + + return nil + } + + var response struct { + Artists []struct { + Thumb *string `json:"strArtistThumb"` + Fanart *string `json:"strArtistFanart"` + Fanart2 *string `json:"strArtistFanart2"` + Fanart3 *string `json:"strArtistFanart3"` + } `json:"artists"` + } + + if err := json.Unmarshal(body, &response); err != nil || len(response.Artists) == 0 { + p.cache.Set(cacheKey, []byte("[]"), artistImageMissCacheTTL, artistMBID, "artist") + + return nil + } + + artist := response.Artists[0] + + var urls []string + + // Thumb is the primary portrait photo; fanart images are wider/background shots. + for _, u := range []*string{artist.Thumb, artist.Fanart, artist.Fanart2, artist.Fanart3} { + if u != nil && *u != "" { + urls = append(urls, *u) + } + } + + data, _ := json.Marshal(urls) + p.cache.Set(cacheKey, data, artistImageCacheTTL, artistMBID, "artist") + + return urls +} + +// --------------------------------------------------------------------------- +// Source 2-4: MB rels + Wikidata + Wikipedia +// --------------------------------------------------------------------------- + +func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation { + cacheKey := "mb:artist-rels:" + artistMBID + + if data, ok := p.cache.Get(cacheKey); ok { + var envelope struct { + Relations []mbRelation `json:"relations"` + } + + if err := json.Unmarshal(data, &envelope); err == nil { + return envelope.Relations + } + } + + url := fmt.Sprintf( + "https://musicbrainz.org/ws/2/artist/%s?fmt=json&inc=url-rels+aliases", + artistMBID, + ) + + if err := p.mbLimiter.Wait(context.Background()); err != nil { + return nil + } + + body, err := p.fetchURL(url) + if err != nil { + // Cache the miss so we don't re-request on every build. + p.cache.Set(cacheKey, []byte("{}"), artistImageMissCacheTTL, artistMBID, "artist") + + return nil + } + + p.cache.Set(cacheKey, body, artistImageCacheTTL, artistMBID, "artist") + + var envelope struct { + Relations []mbRelation `json:"relations"` + } + + if err := json.Unmarshal(body, &envelope); err != nil { + return nil + } + + return envelope.Relations +} + +func (p *ArtistImageProvider) getWikidataQID(rels []mbRelation) string { + for _, rel := range rels { + if rel.Type == "wikidata" { + parts := strings.Split(rel.URL.Resource, "/") + + return parts[len(parts)-1] + } + } + + return "" +} + +func (p *ArtistImageProvider) fetchWikidataP18(qid string) string { + cacheKey := "wikidata-p18:" + qid + + if data, ok := p.cache.Get(cacheKey); ok { + return string(data) + } + + url := fmt.Sprintf( + "%s?action=wbgetclaims&entity=%s&property=P18&format=json", + wikidataAPIBase, qid, + ) + + body, err := p.fetchURL(url) + if err != nil { + return "" + } + + var wd struct { + Claims struct { + P18 []struct { + Mainsnak struct { + Datavalue struct { + Value string `json:"value"` + } `json:"datavalue"` + } `json:"mainsnak"` + } `json:"P18"` + } `json:"claims"` + } + + thumbURL := "" + + if err := json.Unmarshal(body, &wd); err == nil && len(wd.Claims.P18) > 0 { + filename := strings.ReplaceAll(wd.Claims.P18[0].Mainsnak.Datavalue.Value, " ", "_") + thumbURL = wikimediaThumbURL(filename) + } + + p.cache.Set(cacheKey, []byte(thumbURL), artistImageCacheTTL, "", "") + + return thumbURL +} + +func (p *ArtistImageProvider) fetchWikipediaLeadImage(qid string) string { + cacheKey := "wikipedia-lead:" + qid + + if data, ok := p.cache.Get(cacheKey); ok { + return string(data) + } + + // Get the English Wikipedia article title from Wikidata sitelinks. + titleURL := fmt.Sprintf( + "%s?action=wbgetentities&ids=%s&props=sitelinks&sitefilter=enwiki&format=json", + wikidataAPIBase, qid, + ) + + titleBody, err := p.fetchURL(titleURL) + if err != nil { + return "" + } + + var sitelinks struct { + Entities map[string]struct { + Sitelinks map[string]struct { + Title string `json:"title"` + } `json:"sitelinks"` + } `json:"entities"` + } + + if err := json.Unmarshal(titleBody, &sitelinks); err != nil { + return "" + } + + entity, ok := sitelinks.Entities[qid] + if !ok { + return "" + } + + enwiki, ok := entity.Sitelinks["enwiki"] + if !ok || enwiki.Title == "" { + p.cache.Set(cacheKey, []byte(""), artistImageMissCacheTTL, "", "") + + return "" + } + + // Fetch the lead image from Wikipedia. + imgURL := fmt.Sprintf( + "%s?action=query&titles=%s&prop=pageimages&format=json&pithumbsize=%d", + wikipediaAPIBase, + strings.ReplaceAll(enwiki.Title, " ", "_"), + artistImageMaxSize, + ) + + imgBody, err := p.fetchURL(imgURL) + if err != nil { + return "" + } + + var wp struct { + Query struct { + Pages map[string]struct { + Thumbnail struct { + Source string `json:"source"` + } `json:"thumbnail"` + } `json:"pages"` + } `json:"query"` + } + + if err := json.Unmarshal(imgBody, &wp); err != nil { + return "" + } + + leadURL := "" + + for _, page := range wp.Query.Pages { + if page.Thumbnail.Source != "" { + leadURL = page.Thumbnail.Source + + break + } + } + + p.cache.Set(cacheKey, []byte(leadURL), artistImageCacheTTL, "", "") + + return leadURL +} + +// --------------------------------------------------------------------------- +// Disk paths +// --------------------------------------------------------------------------- + +func (p *ArtistImageProvider) artistDir(mbid string) string { + if len(mbid) < 2 { + return filepath.Join(p.baseDir, "xx", mbid) + } + + return filepath.Join(p.baseDir, mbid[:2], mbid) +} + +func (p *ArtistImageProvider) primaryPath(mbid string) string { + return filepath.Join(p.artistDir(mbid), "primary.jpg") +} + +func (p *ArtistImageProvider) isMiss(mbid string) bool { + missPath := filepath.Join(p.artistDir(mbid), ".miss") + _, err := os.Stat(missPath) + + return err == nil +} + +func (p *ArtistImageProvider) writeMiss(mbid string) { + dir := p.artistDir(mbid) + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, ".miss"), []byte{}, 0o644) +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +func (p *ArtistImageProvider) fetchImageBytes(imageURL string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), artistImageTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", lbUserAgent) + + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: HTTP %d", ErrArtistImage, resp.StatusCode) + } + + return io.ReadAll(io.LimitReader(resp.Body, artistImageMaxBytes)) +} + +func (p *ArtistImageProvider) fetchURL(url string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), artistImageTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", lbUserAgent) + + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: HTTP %d", ErrArtistImage, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +func wikimediaThumbURL(filename string) string { + if filename == "" { + return "" + } + + filename = strings.ReplaceAll(filename, " ", "_") + + hash := fmt.Sprintf("%x", md5.Sum([]byte(filename))) //nolint:gosec + h1 := string(hash[0]) + h2 := hash[:2] + + return fmt.Sprintf("%s/%s/%s/%s/%dpx-%s", + wikimediaThumbBase, h1, h2, filename, artistImageMaxSize, filename, + ) +} + +func readFileData(path string) string { + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return "" + } + + mime := "image/jpeg" + if len(data) > 1 && data[0] == 0x89 && data[1] == 0x50 { + mime = "image/png" + } + + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) +} diff --git a/backend/explore/cache.go b/backend/explore/cache.go new file mode 100644 index 0000000..5807ba6 --- /dev/null +++ b/backend/explore/cache.go @@ -0,0 +1,190 @@ +package explore + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "yellowjacket/backend/database" +) + +// Cache provides a SQLite-backed response cache with TTL expiry. +// 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)). +type Cache struct { + db *database.DB + logger *slog.Logger +} + +// NewCache returns a cache backed by the given database connection. +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 http_cache WHERE url_key = ? AND expires_at > datetime('now')", + key, + ) + if err != nil { + c.logger.Warn("http cache get error", + "key", key, + "err", err, + ) + + return nil, false + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + return nil, false + } + + var response string + + if err := rows.Scan(&response); err != nil { + c.logger.Warn("http cache scan error", + "key", key, + "err", err, + ) + + return nil, false + } + + return []byte(response), true +} + +// Set stores a response in the cache with the given TTL. +func (c *Cache) Set( + key string, + data []byte, + ttl time.Duration, + 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 + } + + expr := fmt.Sprintf("datetime('now', '+%d seconds')", seconds) + + query := fmt.Sprintf( + `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("http cache set error", + "key", key, + "err", err, + ) + } +} + +// 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 { + 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("http cache evicted expired entries", + "count", n, + ) + } +} diff --git a/backend/explore/cache_test.go b/backend/explore/cache_test.go new file mode 100644 index 0000000..8feb4d2 --- /dev/null +++ b/backend/explore/cache_test.go @@ -0,0 +1,163 @@ +package explore + +import ( + "database/sql" + "log/slog" + "testing" + "time" + + "yellowjacket/backend/database" +) + +func newTestCache(t *testing.T) *Cache { + t.Helper() + + db := database.NewTestDB(t) + + return NewCache(db, slog.Default()) +} + +func TestCacheSetGet(t *testing.T) { + t.Parallel() + + c := newTestCache(t) + + data := []byte(`{"artist":"Radiohead"}`) + c.Set("https://musicbrainz.org/ws/2/artist?query=radiohead", data, 5*time.Minute, "", "") + + got, ok := c.Get("https://musicbrainz.org/ws/2/artist?query=radiohead") + if !ok { + t.Fatal("expected cache hit, got miss") + } + + if string(got) != string(data) { + t.Errorf("got %q, want %q", string(got), string(data)) + } +} + +func TestCacheMiss(t *testing.T) { + t.Parallel() + + c := newTestCache(t) + + _, ok := c.Get("https://nonexistent.example.com/api") + if ok { + t.Error("expected cache miss, got hit") + } +} + +func TestCacheTTLExpiry(t *testing.T) { + c := newTestCache(t) + + data := []byte(`{"ephemeral":true}`) + c.Set("ttl-test-key", data, 1*time.Second, "", "") + + // Verify it's there immediately. + if _, ok := c.Get("ttl-test-key"); !ok { + t.Fatal("expected cache hit immediately after set") + } + + // Wait for expiry. + time.Sleep(2 * time.Second) + + if _, ok := c.Get("ttl-test-key"); ok { + t.Error("expected cache miss after TTL expiry, got hit") + } +} + +func TestCacheMBID(t *testing.T) { + t.Parallel() + + // explore_cache was replaced by http_cache + artist_metadata in + // migration 27; this test queries the old table directly and is + // obsolete until rewritten against the new schemas. + t.Skip("explore_cache dropped by migration 27; test is obsolete") + + c := newTestCache(t) + + data := []byte(`{"name":"OK Computer"}`) + c.Set( + "mbid-test-key", + data, + 10*time.Minute, + "b3b40b1b-3c03-4b8a-8291-8e1f2d09e211", + "release_group", + ) + + // Query the MBID column directly to verify it was stored. + db := c.db + + rows, err := db.QueryContext( + "SELECT mbid, entity_type FROM explore_cache WHERE url_key = ?", + "mbid-test-key", + ) + if err != nil { + t.Fatalf("query explore_cache: %v", err) + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + t.Fatal("explore_cache row not found") + } + + var ( + mbid sql.NullString + entityType sql.NullString + ) + + if err := rows.Scan(&mbid, &entityType); err != nil { + t.Fatalf("scan: %v", err) + } + + if !mbid.Valid || mbid.String != "b3b40b1b-3c03-4b8a-8291-8e1f2d09e211" { + t.Errorf("mbid = %v, want b3b40b1b-3c03-4b8a-8291-8e1f2d09e211", mbid) + } + + if !entityType.Valid || entityType.String != "release_group" { + t.Errorf("entity_type = %v, want release_group", entityType) + } +} + +func TestCacheEvict(t *testing.T) { + // explore_cache was replaced by http_cache + artist_metadata in + // migration 27; this test queries the old table directly and is + // obsolete until rewritten against the new schemas. + t.Skip("explore_cache dropped by migration 27; test is obsolete") + + c := newTestCache(t) + + // Insert an entry that expires in 1 second. + c.Set("evict-key", []byte(`{}`), 1*time.Second, "", "") + + time.Sleep(2 * time.Second) + + // Evict expired entries. + c.Evict() + + // Verify the row is gone entirely (not just expired-but-present). + db := c.db + + rows, err := db.QueryContext( + "SELECT COUNT(*) FROM explore_cache WHERE url_key = ?", + "evict-key", + ) + if err != nil { + t.Fatalf("query: %v", err) + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + t.Fatal("no row returned") + } + + var count int64 + if err := rows.Scan(&count); err != nil { + t.Fatalf("scan: %v", err) + } + + if count != 0 { + t.Errorf("expected 0 rows after evict, got %d", count) + } +} diff --git a/backend/explore/coverart.go b/backend/explore/coverart.go new file mode 100644 index 0000000..cf7f252 --- /dev/null +++ b/backend/explore/coverart.go @@ -0,0 +1,36 @@ +package explore + +import "fmt" + +const ( + coverArtBaseURL = "https://coverartarchive.org/release" + coverArtGroupBaseURL = "https://coverartarchive.org/release-group" +) + +// CoverArtURL returns the Cover Art Archive URL for the 250px +// front cover of the given release MBID. +func CoverArtURL(releaseMBID string) string { + return fmt.Sprintf("%s/%s/front-250", coverArtBaseURL, releaseMBID) +} + +// CoverArtURLSize returns the Cover Art Archive URL for the front +// cover of the given release MBID at the specified pixel size. +// Common sizes are 250, 500, and 1200. +func CoverArtURLSize(releaseMBID string, size int) string { + return fmt.Sprintf("%s/%s/front-%d", coverArtBaseURL, releaseMBID, size) +} + +// CoverArtGroupURL returns the Cover Art Archive URL for the 250px +// front cover of the given release group MBID. Search results +// return release group MBIDs (not release MBIDs), so this is the +// correct endpoint for displaying cover art in search results. +func CoverArtGroupURL(releaseGroupMBID string) string { + return fmt.Sprintf("%s/%s/front-250", coverArtGroupBaseURL, releaseGroupMBID) +} + +// CoverArtGroupURLSize returns the Cover Art Archive URL for the +// front cover of the given release group MBID at the specified +// pixel size. Common sizes are 250, 500, and 1200. +func CoverArtGroupURLSize(releaseGroupMBID string, size int) string { + return fmt.Sprintf("%s/%s/front-%d", coverArtGroupBaseURL, releaseGroupMBID, size) +} diff --git a/backend/explore/coverart_test.go b/backend/explore/coverart_test.go new file mode 100644 index 0000000..616f393 --- /dev/null +++ b/backend/explore/coverart_test.go @@ -0,0 +1,97 @@ +package explore_test + +import ( + "testing" + + "yellowjacket/backend/explore" +) + +func TestCoverArtURL(t *testing.T) { + t.Parallel() + + mbid := "76df3287-6cda-33eb-8e9a-044b5e15c37c" + + got := explore.CoverArtURL(mbid) + want := "https://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15c37c/front-250" + + if got != want { + t.Errorf("CoverArtURL(%q) = %q, want %q", mbid, got, want) + } +} + +func TestCoverArtURLSize(t *testing.T) { + t.Parallel() + + mbid := "76df3287-6cda-33eb-8e9a-044b5e15c37c" + + tests := []struct { + size int + want string + }{ + { + 250, + "https://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15c37c/front-250", + }, + { + 500, + "https://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15c37c/front-500", + }, + { + 1200, + "https://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15c37c/front-1200", + }, + } + + for _, tt := range tests { + got := explore.CoverArtURLSize(mbid, tt.size) + if got != tt.want { + t.Errorf("CoverArtURLSize(%q, %d) = %q, want %q", + mbid, tt.size, got, tt.want) + } + } +} + +func TestCoverArtGroupURL(t *testing.T) { + t.Parallel() + + mbid := "abc-123" + + got := explore.CoverArtGroupURL(mbid) + want := "https://coverartarchive.org/release-group/abc-123/front-250" + + if got != want { + t.Errorf("CoverArtGroupURL(%q) = %q, want %q", mbid, got, want) + } +} + +func TestCoverArtGroupURLSize(t *testing.T) { + t.Parallel() + + mbid := "abc-123" + + tests := []struct { + size int + want string + }{ + { + 250, + "https://coverartarchive.org/release-group/abc-123/front-250", + }, + { + 500, + "https://coverartarchive.org/release-group/abc-123/front-500", + }, + { + 1200, + "https://coverartarchive.org/release-group/abc-123/front-1200", + }, + } + + for _, tt := range tests { + got := explore.CoverArtGroupURLSize(mbid, tt.size) + if got != tt.want { + t.Errorf("CoverArtGroupURLSize(%q, %d) = %q, want %q", + mbid, tt.size, got, tt.want) + } + } +} diff --git a/backend/explore/coverartproxy.go b/backend/explore/coverartproxy.go new file mode 100644 index 0000000..7b696ca --- /dev/null +++ b/backend/explore/coverartproxy.go @@ -0,0 +1,374 @@ +package explore + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "yellowjacket/backend/database" + "yellowjacket/backend/system" +) + +// ErrCoverArt is returned when the Cover Art Archive responds +// with a non-200 status code. +var ErrCoverArt = errors.New("cover art fetch failed") + +const ( + // thumbnailDir is the subdirectory under the user data dir + // where cached cover art thumbnails are stored. + thumbnailDir = "cover-art-cache" + + // thumbnailTimeout is the HTTP timeout for fetching a thumbnail. + thumbnailTimeout = 10 * time.Second + + // thumbnailMaxSize is the maximum image size to cache (2 MB). + thumbnailMaxSize = 2 * 1024 * 1024 +) + +// CoverArtProxy fetches and caches cover art thumbnails locally. +// It checks three sources in order: +// 1. Local library cover art (instant, matched by album+artist name) +// 2. Disk cache from a previous CAA fetch (instant) +// 3. Cover Art Archive network fetch (slow, cached to disk) +type CoverArtProxy struct { + db *database.DB + cacheDir string + client *http.Client + limiter *RateLimiter + + mu sync.Mutex // serializes disk writes + libOnce sync.Once + libIndex map[string]string // "album\x00artist" → cover art file path +} + +// NewCoverArtProxy creates a proxy that checks the local library +// first and caches CAA thumbnails under the user data directory. +func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy { + dir := "" + + dataDir, err := system.GetUserDataDirPath() + if err == nil { + dir = filepath.Join(dataDir, thumbnailDir) + _ = os.MkdirAll(dir, 0o755) + } + + return &CoverArtProxy{ + db: db, + cacheDir: dir, + client: &http.Client{Timeout: thumbnailTimeout}, + limiter: limiter, + } +} + +// GetThumbnail returns a base64-encoded JPEG data URL for the given +// release group. Checks local library art first (by name match), +// GetThumbnail returns a base64 data URL for an album's cover art. +// Checks local library art first, then disk cache, then fetches from CAA. +// Returns "" on failure. +// +// 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+2: local library art + disk cache (instant). + if cached := p.GetThumbnailCached(releaseGroupMBID, albumName, artistName); cached != "" { + return cached + } + + if p.cacheDir == "" || releaseGroupMBID == "" { + return "" + } + + // Source 3: fetch from Cover Art Archive (slow, cached to disk). + url := CoverArtGroupURL(releaseGroupMBID) + data, cacheable, err := p.fetch(url) + + if err != nil || len(data) == 0 { + if cacheable { + p.writeCache(releaseGroupMBID, nil) + } + + return "" + } + + p.writeCache(releaseGroupMBID, data) + + 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 +// --------------------------------------------------------------------------- + +// libraryArt returns a base64 data URL for the album if it exists +// in the local music library. Matched by lowercased album name + +// artist name. +func (p *CoverArtProxy) libraryArt(albumName, artistName string) string { + p.libOnce.Do(p.buildLibraryIndex) + + key := libraryArtKey(albumName, artistName) + + path, ok := p.libIndex[key] + if !ok || path == "" { + return "" + } + + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return "" + } + + mime := "image/jpeg" + if strings.HasSuffix(strings.ToLower(path), ".png") { + mime = "image/png" + } + + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) +} + +func (p *CoverArtProxy) buildLibraryIndex() { + p.libIndex = make(map[string]string) + + if p.db == nil { + return + } + + rows, err := p.db.QueryContext(` + SELECT rg.name, a.name, ca.file_path + FROM release_groups rg + JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id + JOIN artist_credit_artist aca ON aca.credit_id = ac.id + JOIN artists a ON a.id = aca.artist_id + LEFT JOIN cover_art ca ON ca.id = rg.cover_art_id + WHERE ca.file_path IS NOT NULL AND ca.file_path != '' + `) + if err != nil { + return + } + + defer func() { _ = rows.Close() }() + + for rows.Next() { + var album, artist, path string + if err := rows.Scan(&album, &artist, &path); err == nil { + key := libraryArtKey(album, artist) + p.libIndex[key] = path + } + } +} + +func libraryArtKey(album, artist string) string { + return strings.ToLower(album) + "\x00" + strings.ToLower(artist) +} + +// --------------------------------------------------------------------------- +// Source 2+3: CAA disk cache and network fetch +// --------------------------------------------------------------------------- + +func (p *CoverArtProxy) fetch(url string) ([]byte, bool, error) { + ctx := context.Background() + if err := p.limiter.Wait(ctx); err != nil { + return nil, false, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, false, err + } + + req.Header.Set("User-Agent", lbUserAgent) + + resp, err := p.client.Do(req) + if err != nil { + return nil, false, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, true, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("%w: %d", ErrCoverArt, resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, thumbnailMaxSize)) + if err != nil { + return nil, false, err + } + + return data, true, nil +} + +func (p *CoverArtProxy) cachePath(mbid string) string { + return filepath.Join(p.cacheDir, mbid+".jpg") +} + +func (p *CoverArtProxy) readCache(mbid string) string { + path := p.cachePath(mbid) + + data, err := os.ReadFile(path) + if err != nil { + return "" + } + + if len(data) == 0 { + return "" + } + + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data) +} + +func (p *CoverArtProxy) writeCache(mbid string, data []byte) { + p.mu.Lock() + defer p.mu.Unlock() + + path := p.cachePath(mbid) + + if data == nil { + data = []byte{} + } + + _ = os.WriteFile(path, data, 0o644) +} diff --git a/backend/explore/explore.go b/backend/explore/explore.go new file mode 100644 index 0000000..e205de4 --- /dev/null +++ b/backend/explore/explore.go @@ -0,0 +1,3498 @@ +package explore + +import ( + "context" + "log/slog" + "math" + "sort" + "strings" + "sync" + "time" + + "yellowjacket/backend/database" +) + +// Service is the Wails-bound service for the explore feature. +// It owns the lifecycle of all explore-related components: the +// MusicBrainz client, ListenBrainz client, rate limiter, and +// response cache. Its exported methods form the binding surface +// that the frontend calls via generated TypeScript stubs. +type Service struct { + mb *MusicBrainzClient + lb *ListenBrainzClient + cache *Cache + index *SearchIndex + artProxy *CoverArtProxy + artistImg *ArtistImageProvider + libMBID *LibraryMBIDIndex + db *database.DB + logger *slog.Logger + ctx context.Context +} + +// NewExploreService creates a Service backed by the given +// database. It instantiates the rate limiter, cache, MusicBrainz +// client, and ListenBrainz client internally. +func NewExploreService(logger *slog.Logger, db *database.DB) *Service { + cache := NewCache(db, logger.WithGroup("cache")) + lbLimiter := NewRateLimiter() + // 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, caaLimiter) + artistImg := NewArtistImageProvider( + db, cache, mbBackgroundLimiter, logger.WithGroup("artist-image"), + ) + index := NewSearchIndex(db, lb, artistImg, logger.WithGroup("search-index")) + index.MarkReadyIfPopulated() // make index queryable immediately if data exists + libMBID := NewLibraryMBIDIndex(db) + + logger.Info("explore service created") + + return &Service{ + mb: mb, + lb: lb, + cache: cache, + index: index, + artProxy: artProxy, + artistImg: artistImg, + libMBID: libMBID, + db: db, + logger: logger, + ctx: context.Background(), + } +} + +// SetContext injects the Wails runtime context. Called from +// 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. +// Call this after the library scan completes so the indexer doesn't +// starve the scan for DB access. +func (e *Service) StartIndexBuild() { + e.index.StartBuild(e.ctx) +} + +// IndexNewArtists indexes only library artists not yet in the search +// index. Lightweight post-scan path — skips the full tier machinery. +func (e *Service) IndexNewArtists() { + e.index.IndexNewArtists(e.ctx) +} + +// StopIndexBuild cancels the background search index build. +// Call before a full rescan to free the DB for the scan. +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. +func (e *Service) InvalidateIndexDiscographies() { + e.index.InvalidateDiscographies() +} + +// --------------------------------------------------------------------------- +// MusicBrainz search +// --------------------------------------------------------------------------- + +// SearchArtists queries MusicBrainz for artists matching the query. +func (e *Service) SearchArtists(query string) ([]MBArtist, error) { + artists, _, err := e.mb.SearchArtists(e.ctx, query, mbSearchLimit) + return artists, err +} + +// SearchReleaseGroups queries MusicBrainz for release groups matching the query. +func (e *Service) SearchReleaseGroups(query string) ([]MBReleaseGroup, error) { + rgs, _, err := e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit) + return rgs, err +} + +// SearchRecordings queries MusicBrainz for recordings matching the query. +func (e *Service) SearchRecordings(query string) ([]MBRecording, error) { + recs, _, err := e.mb.SearchRecordings(e.ctx, query, mbSearchLimit) + return recs, err +} + +// SearchLocal queries only the local FTS5 index and returns results +// instantly with no network calls. Returns nil if the index isn't +// 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, indexSearchLimit) + if len(indexHits) == 0 { + return nil + } + + var result MBSearchResult + mergeIndexHits(&result, indexHits) + + // Remove special-purpose artists from local results too. + if len(result.Artists) > 0 { + filtered := result.Artists[:0] + for _, a := range result.Artists { + if !mbSpecialPurposeArtists[a.MBID] { + filtered = append(filtered, a) + } + } + result.Artists = filtered + } + + // Cap counts but skip the minBlendedScore filter — index hits + // use scalePopularity scores that shouldn't be compared to + // blended MB+LB scores. + if len(result.Artists) > maxResults { + result.Artists = result.Artists[:maxResults] + } + + if len(result.ReleaseGroups) > maxResults { + result.ReleaseGroups = result.ReleaseGroups[:maxResults] + } + + if len(result.Recordings) > maxResults { + result.Recordings = result.Recordings[:maxResults] + } + + return &result +} + +// --------------------------------------------------------------------------- +// MusicBrainz lookup +// --------------------------------------------------------------------------- + +// 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) +} + +// --------------------------------------------------------------------------- +// MusicBrainz browse +// --------------------------------------------------------------------------- + +// 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. + 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) { + 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 +} + +// --------------------------------------------------------------------------- +// ListenBrainz +// --------------------------------------------------------------------------- + +// 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 + } + + // 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 + 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 { + return nil + } + + defer func() { _ = rows.Close() }() + + var result []LBSimilarArtist + + for rows.Next() { + var a LBSimilarArtist + + if err := rows.Scan(&a.ArtistMBID, &a.Name, &a.Score); err == nil { + result = append(result, a) + } + } + + return result +} + +// --------------------------------------------------------------------------- +// Cover Art Archive +// --------------------------------------------------------------------------- + +// CoverArtURL returns the Cover Art Archive URL for a release's +// front cover at the default 250px size. +func (e *Service) CoverArtURL(releaseMBID string) string { + return CoverArtURL(releaseMBID) +} + +// CoverArtGroupURL returns the Cover Art Archive URL for a release +// group's front cover at the default 250px size. This is the +// correct endpoint for search results, which return release group +// MBIDs rather than individual release MBIDs. +func (e *Service) CoverArtGroupURL(releaseGroupMBID string) string { + return CoverArtGroupURL(releaseGroupMBID) +} + +// GetThumbnail returns a base64 data URL for the release group's +// cover art. Checks local library art first (by album+artist +// name), then disk cache, then Cover Art Archive. +// Returns "" if no cover art is available. +func (e *Service) GetThumbnail(releaseGroupMBID, albumName, artistName string) string { + 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"` + AlbumName string `json:"albumName"` + ArtistName string `json:"artistName"` +} + +// 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.GetThumbnailCached(req.MBID, req.AlbumName, req.ArtistName) + if dataURL != "" { + result[req.MBID] = dataURL + } + } + + return result +} + +// GetArtistImageURL returns a base64 data URL for the artist's +// photo. Cached on disk — first call resolves via MB/Wikidata and +// fetches from Wikimedia Commons, subsequent calls are instant. +// Returns "" if no image is available. +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"). +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 { + return e.libMBID.GetArtistMBID(artistName) +} + +// GetArtistImages resolves artist images for multiple artists by +// name in one call. Returns a map of artist name → base64 data +// URL. Only artists with cached images are returned — no network +// fetches are triggered (use GetArtistImageURL for on-demand fetch). +func (e *Service) GetArtistImages(names []string) map[string]string { + result := make(map[string]string, len(names)) + + // Batch resolve all names → MBIDs from the library DB. + allMBIDs := e.libMBID.AllArtistMBIDs() + + for _, name := range names { + mbid, ok := allMBIDs[name] + if !ok || mbid == "" { + continue + } + + // Only return already-cached images — don't trigger fetches. + img := e.artistImg.GetCachedImage(mbid) + if img != "" { + result[name] = img + } + } + + return result +} + +// Search concurrently queries MusicBrainz for artists, release +// groups, and recordings matching the query, then boosts results +// using ListenBrainz popularity data. The final score blends +// text relevance (60%) with log-scaled listen counts (40%). +// +// If any sub-search or popularity lookup fails the error is logged +// and the remaining results are still returned — popularity +// failures degrade to MB-only ordering. +func (e *Service) Search(query string) (*MBSearchResult, error) { + searchStart := time.Now() + + // 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, indexSearchLimit) //nolint:mnd + p0Dur := time.Since(p0Start) + + e.logger.Info("search phase 0 complete (index)", + "query", query, + "hits", len(indexHits), + "elapsed", p0Dur, + ) + + // 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) + defer mbCancel() + + var ( + result MBSearchResult + mu sync.Mutex + 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() + } + + searches := []searchFunc{ + { + name: "artists", + fn: func() { + t := time.Now() + 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, + ) + + if err != nil { + e.logger.Warn("search sub-call failed", + "entity", "artists", + "query", query, + "error", err, + ) + + return + } + + mu.Lock() + initial.artists = artists + initial.artistN = total + mu.Unlock() + }, + }, + { + name: "releaseGroups", + fn: func() { + t := time.Now() + 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, + ) + + if err != nil { + e.logger.Warn("search sub-call failed", + "entity", "releaseGroups", + "query", query, + "error", err, + ) + + return + } + + mu.Lock() + initial.rgs = rgs + initial.rgN = total + mu.Unlock() + }, + }, + { + name: "recordings", + fn: func() { + t := time.Now() + 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, + ) + + if err != nil { + e.logger.Warn("search sub-call failed", + "entity", "recordings", + "query", query, + "error", err, + ) + + return + } + + mu.Lock() + initial.recordings = recs + initial.recN = total + mu.Unlock() + }, + }, + } + + wg.Add(len(searches)) + + for _, s := range searches { + go func() { + defer wg.Done() + + s.fn() + }() + } + + 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)", + "query", query, + "artists", len(result.Artists), + "releaseGroups", len(result.ReleaseGroups), + "recordings", len(result.Recordings), + "expanded", len(result.Artists) > mbSearchLimit || len(result.ReleaseGroups) > mbSearchLimit || len(result.Recordings) > mbSearchLimit, + "elapsed", p1Dur.Round(time.Millisecond), + ) + + // Phases 2+3: when the index is ready, use cached popularity + // from the index to rerank MB results (no API calls). + // When the index isn't ready, fall back to live LB API calls. + p2Start := time.Now() + indexReady := e.index.IsReady() + + // Phase 2a: resolve artist popularity and library membership. + artistMBIDs := make([]string, 0, len(result.Artists)) + for _, a := range result.Artists { + if a.MBID != "" { + artistMBIDs = append(artistMBIDs, a.MBID) + } + } + + artistPop := make(map[string]int) + libMBIDs := make(map[string]bool) + simScores := make(map[string]int) + + 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 { + 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) + } + } + + 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 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 + } + } + + rerankArtistsPersonalized(result.Artists, artistPop, libMBIDs, simScores) + + // Phase 2b: rerank release groups and recordings. + if indexReady { + e.boostWithIndexPopularityRGsAndRecs(&result) + } else { + // Slow path: LB popularity + cross-reference in parallel. + slowCtx, slowCancel := context.WithTimeout(e.ctx, searchSlowPathTimeout) + + var wgSlow sync.WaitGroup + wgSlow.Add(2) //nolint:mnd + + // Leg 1: LB popularity for RGs and recordings. + go func() { + defer wgSlow.Done() + e.boostWithPopularityRGsAndRecs(&result) + }() + + // Leg 2: cross-reference artist discographies. + go func() { + defer wgSlow.Done() + if slowCtx.Err() == nil { + e.crossReferenceAlbums(slowCtx, query, &result) + } + }() + + wgSlow.Wait() + slowCancel() + } + + p2Dur := time.Since(p2Start) + + e.logger.Info("search phase 2-3 complete (rerank)", + "query", query, + "indexReady", indexReady, + "elapsed", p2Dur.Round(time.Millisecond), + ) + + // Phase 4: merge local index hits into results, dedup by MBID. + mergeIndexHits(&result, indexHits) + + // Phase 5: boost exact/substring name matches so a search for + // "the teenagers" ranks "The Teenagers" above "The Beatles" + // even when The Beatles have vastly more listens. + e.boostNameMatches(query, &result) + + // 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", + "query", query, + "artists", len(result.Artists), + "releaseGroups", len(result.ReleaseGroups), + "recordings", len(result.Recordings), + "total", totalDur.Round(time.Millisecond), + "phase0", p0Dur.Round(time.Millisecond), + "phase1_mb", p1Dur.Round(time.Millisecond), + "phase2_rerank", p2Dur.Round(time.Millisecond), + ) + + return &result, nil +} + +// --------------------------------------------------------------------------- +// Cross-reference search +// --------------------------------------------------------------------------- + +const ( + // crossRefArtists is the number of top artists whose + // discographies are searched for matching albums. + crossRefArtists = 3 + + // crossRefMinRatio is the minimum fuzzy match ratio (0–1) + // for an album title to be considered a match. + crossRefMinRatio = 0.4 +) + +// crossReferenceAlbums browses the discographies of the top N +// artists and fuzzy-matches the query against album titles. +// Matched albums not already in result.ReleaseGroups are injected +// at the front. This handles queries like "for you tatsuro" +// where MB text search can't associate the title with the artist. +func (e *Service) crossReferenceAlbums(ctx context.Context, query string, result *MBSearchResult) { + if len(result.Artists) == 0 { + return + } + + limit := crossRefArtists + if limit > len(result.Artists) { + limit = len(result.Artists) + } + + topArtists := result.Artists[:limit] + queryLower := strings.ToLower(strings.TrimSpace(query)) + + // Build a set of release group MBIDs already in results. + existing := make(map[string]bool, len(result.ReleaseGroups)) + for _, rg := range result.ReleaseGroups { + existing[rg.MBID] = true + } + + // Browse discographies concurrently. + type match struct { + rg MBReleaseGroup + ratio float64 + } + + var ( + matches []match + mu sync.Mutex + wg sync.WaitGroup + ) + + wg.Add(limit) + + for _, artist := range topArtists { + go func(a MBArtist) { + defer wg.Done() + + rgs, err := e.mb.BrowseReleaseGroups(ctx, a.MBID) + if err != nil { + e.logger.Warn("cross-reference browse failed", + "artist", a.Name, + "mbid", a.MBID, + "error", err, + ) + + return + } + + for _, rg := range rgs { + if existing[rg.MBID] { + continue + } + + ratio := fuzzyMatchRatio(queryLower, strings.ToLower(rg.Title)) + if ratio >= crossRefMinRatio { + mu.Lock() + + matches = append(matches, match{rg: rg, ratio: ratio}) + + mu.Unlock() + } + } + }(artist) + } + + wg.Wait() + + if len(matches) == 0 { + return + } + + // Sort by match ratio descending. + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].ratio > matches[j].ratio + }) + + // Inject at the front of release groups. + injected := make([]MBReleaseGroup, 0, len(matches)) + + for _, m := range matches { + if !existing[m.rg.MBID] { + injected = append(injected, m.rg) + existing[m.rg.MBID] = true + } + } + + if len(injected) > 0 { + result.ReleaseGroups = append(injected, result.ReleaseGroups...) + + e.logger.Info("cross-reference injected albums", + "count", len(injected), + "topMatch", injected[0].Title, + ) + } +} + +// fuzzyMatchRatio computes a similarity score between query and +// title. It checks: +// 1. Whether the title appears as a substring of the query (or +// vice versa) — handles "for you tatsuro" containing "for you" +// 2. Word overlap ratio as a fallback +// +// Returns 0–1 where 1 is a perfect match. +func fuzzyMatchRatio(query, title string) float64 { + if query == title { + return 1.0 + } + + // Substring containment: "for you tatsuro" contains "for you". + // Use both character ratio and word ratio, take the higher one. + if strings.Contains(query, title) || strings.Contains(title, query) { + shorter := len(title) + longer := len(query) + + if shorter > longer { + shorter, longer = longer, shorter + } + + charRatio := float64(shorter) / float64(longer) + + // Also check word-level ratio for short titles in long queries. + titleWords := strings.Fields(title) + queryWords := strings.Fields(query) + + wordRatio := float64(len(titleWords)) / float64(len(queryWords)) + if len(titleWords) > len(queryWords) { + wordRatio = float64(len(queryWords)) / float64(len(titleWords)) + } + + if wordRatio > charRatio { + return wordRatio + } + + return charRatio + } + + // Word overlap: count how many query words appear in the title. + queryWords := strings.Fields(query) + titleWords := strings.Fields(title) + + if len(queryWords) == 0 || len(titleWords) == 0 { + return 0 + } + + titleSet := make(map[string]bool, len(titleWords)) + for _, w := range titleWords { + titleSet[w] = true + } + + hits := 0 + + for _, w := range queryWords { + if titleSet[w] { + hits++ + } + } + + return float64(hits) / float64(len(queryWords)) +} + +// --------------------------------------------------------------------------- +// Index result merging +// --------------------------------------------------------------------------- + +// mergeIndexHits injects local popularity index results into the +// MBSearchResult. Index hits for entity types not already present +// (by MBID) are prepended so they appear first — they come from +// the most popular albums/tracks globally and deserve prominence. +func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) { + if len(hits) == 0 { + return + } + + // Build MBID sets for existing results. + artistMBIDs := make(map[string]bool, len(result.Artists)) + for _, a := range result.Artists { + artistMBIDs[a.MBID] = true + } + + rgMBIDs := make(map[string]bool, len(result.ReleaseGroups)) + for _, rg := range result.ReleaseGroups { + rgMBIDs[rg.MBID] = true + } + + 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, + 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 + } + + case "release_group": + if !rgMBIDs[h.MBID] { + score := int(float64(scalePopularity(h.Popularity)) * 0.5) + + var secondary []string + if h.SecondaryTypes != "" { + secondary = strings.Split(h.SecondaryTypes, ",") + } + + 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": + 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 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...) + } + + 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 +// comparable with MB/blended scores. Uses log scaling. +func scalePopularity(listens int) int { + if listens <= 0 { + return 0 + } + + // log10(1M) ≈ 6, log10(10M) ≈ 7. Scale so 1M+ listens → ~80-100. + const scale = 15.0 // tuned so ~100K listens → ~75, ~1M → ~90 + + score := int(math.Log10(float64(listens)) * scale) + if score > 100 { //nolint:mnd + score = 100 + } + + return score +} + +// --------------------------------------------------------------------------- +// Filtering and capping +// --------------------------------------------------------------------------- + +func filterAndCap(result *MBSearchResult) { + // 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 + } + + if a.Score < minBlendedScore { + 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] + + for _, r := range result.Recordings { + if r.Score >= minBlendedScore { + filtered = append(filtered, r) + } + } + + result.Recordings = filtered + } + + // Cap each slice. + if len(result.Artists) > maxResults { + result.Artists = result.Artists[:maxResults] + } + + if len(result.ReleaseGroups) > maxResults { + result.ReleaseGroups = result.ReleaseGroups[:maxResults] + } + + if len(result.Recordings) > maxResults { + result.Recordings = result.Recordings[:maxResults] + } +} + +// --------------------------------------------------------------------------- +// Popularity-boosted reranking +// --------------------------------------------------------------------------- + +const ( + // 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 + + // 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 = 3 * time.Second + + // searchSlowPathTimeout caps the total time spent on the slow + // path (LB popularity + cross-referencing). When the index + // isn't ready, these API calls can stack up — especially + // cross-referencing, which browses 3 artist discographies via + // MB and can hit 429 retries. The timeout ensures search + // returns within a reasonable window. + searchSlowPathTimeout = 3 * time.Second + + // maxResults caps each entity slice after filtering. + maxResults = 15 + + // minBlendedScore is the absolute floor — no result survives + // below this regardless of popularity. + minBlendedScore = 15 + + // 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). 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.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.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 +// Artist MBIDs that should be excluded from search results. These +// are placeholder entries (e.g. [unknown], [anonymous]) that +// accumulate thousands of recordings and artificially high +// popularity, polluting search results. +// +// See: https://musicbrainz.org/doc/Style/Unknown_and_untitled/Special_purpose_artist +// +//nolint:gochecknoglobals +var mbSpecialPurposeArtists = map[string]bool{ + "125ec42a-7229-4250-afc5-e057484327fe": true, // [unknown] + "f731ccc4-e22a-43af-a747-64213f8768e7": true, // [anonymous] + "33cf029c-63b0-41a0-9855-be2a3665fb3b": true, // [data] + "314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc": true, // [dialogue] + "eec63d3c-3b81-4ad4-b1e4-7c147c4d2b61": true, // [no artist] + "9be7f096-97ec-4615-8957-8c3b659f51b4": true, // [traditional] + "80a8851f-444c-4539-892b-ad2a49f7f0d0": true, // [Church bells] + "ae636985-40e8-4fe2-80cb-9c1a21c6e30a": true, // Various Artists (SPA, accumulates bogus popularity) + "89ad4ac3-39f7-470e-963a-56509c546377": true, // Various Artists (regular MBID, same issue) +} + +// boostWithIndexPopularity reranks MB search results using +// popularity data from the local search index. No API calls — +// just SQLite lookups. This is the fast path used when the index +// is ready. +func (e *Service) boostWithIndexPopularity(result *MBSearchResult) { + // Collect all MBIDs across all entity types. + allMBIDs := make([]string, 0, + len(result.Artists)+len(result.ReleaseGroups)+len(result.Recordings)) + + for _, a := range result.Artists { + if a.MBID != "" { + allMBIDs = append(allMBIDs, a.MBID) + } + } + + for _, rg := range result.ReleaseGroups { + if rg.MBID != "" { + allMBIDs = append(allMBIDs, rg.MBID) + } + } + + for _, r := range result.Recordings { + if r.MBID != "" { + allMBIDs = append(allMBIDs, r.MBID) + } + } + + // Single batch query for all popularity + in_library data. + batch := e.index.GetPopularityBatch(allMBIDs) + if batch == nil { + return + } + + // Build per-entity maps from the batch result. + artistPop := make(map[string]int, len(result.Artists)) + for i, a := range result.Artists { + if pop, ok := batch.Popularity[a.MBID]; ok { + artistPop[a.MBID] = pop + result.Artists[i].HasPopularity = true + result.Artists[i].Popularity = pop + } + + if batch.InLibrary[a.MBID] { + result.Artists[i].InLibrary = true + } + } + + rerankArtistsPersonalized(result.Artists, artistPop, batch.InLibrary, batch.SimilarityScores) + + rgPop := make(map[string]int, len(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 + } + } + + rerankReleaseGroupsPersonalized(result.ReleaseGroups, rgPop, batch.InLibrary, batch.SimilarityScores) + + recPop := make(map[string]int, len(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 + } + } + + rerankRecordingsPersonalized(result.Recordings, recPop, batch.InLibrary, batch.SimilarityScores) +} + +// boostWithIndexPopularityRGsAndRecs reranks release groups and +// recordings using index popularity. Artists are handled separately +// via the always-on LB API lookup. +func (e *Service) boostWithIndexPopularityRGsAndRecs(result *MBSearchResult) { + allMBIDs := make([]string, 0, + len(result.ReleaseGroups)+len(result.Recordings)) + + for _, rg := range result.ReleaseGroups { + if rg.MBID != "" { + allMBIDs = append(allMBIDs, rg.MBID) + } + } + + for _, r := range result.Recordings { + if r.MBID != "" { + allMBIDs = append(allMBIDs, r.MBID) + } + } + + if len(allMBIDs) == 0 { + return + } + + batch := e.index.GetPopularityBatch(allMBIDs) + if batch == nil { + 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 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 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) +} + +// 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() + + 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 + } + + rgMBIDs := make([]string, len(result.ReleaseGroups)) + for i, rg := range result.ReleaseGroups { + rgMBIDs[i] = rg.MBID + } + + // Fetch popularity concurrently (2 POST calls). + var ( + recordingPopData map[string]PopularityData + rgPopData map[string]PopularityData + wg sync.WaitGroup + ) + + wg.Add(2) //nolint:mnd + + go func() { + defer wg.Done() + + pop, err := e.lb.RecordingPopularity(e.ctx, recordingMBIDs) + if err != nil { + e.logger.Warn("popularity lookup failed", "entity", "recording", "error", err) + + return + } + + recordingPopData = pop + }() + + go func() { + defer wg.Done() + + pop, err := e.lb.ReleaseGroupPopularity(e.ctx, rgMBIDs) + if err != nil { + e.logger.Warn("popularity lookup failed", "entity", "releaseGroup", "error", err) + + return + } + + rgPopData = pop + }() + + wg.Wait() + + // Backfill index with popularity data for future searches. + if recordingPopData != nil { + go e.index.BackfillPopularity(recordingPopData) + } + + if rgPopData != nil { + go e.index.BackfillPopularity(rgPopData) + } + + rerankRecordings(result.Recordings, listenCounts(recordingPopData)) + rerankReleaseGroups(result.ReleaseGroups, listenCounts(rgPopData)) +} + +// boostNameMatches re-sorts artists and release groups so that +// exact or substring name matches rank above results that only +// matched on common words like "the". Without this, a search +// for "the teenagers" would rank The Beatles above The Teenagers +// because The Beatles' massive popularity compensates for their +// weak text relevance on the word "the". +// +// The boost is applied after popularity reranking so it acts as +// a final tiebreaker that respects user intent. +func (e *Service) boostNameMatches(query string, result *MBSearchResult) { + q := strings.ToLower(strings.TrimSpace(query)) + if q == "" { + return + } + + // Apply tier multiplier to artist scores. Percentage-based so the + // boost scales with the artist's existing score — a popular + // near-match can overcome an unpopular exact match when the + // popularity gap is proportionally larger than the tier difference. + if len(result.Artists) > 1 { + for i := range result.Artists { + tier := nameMatchTier(q, strings.ToLower(result.Artists[i].Name)) + result.Artists[i].Score = int(float64(result.Artists[i].Score) * (1.0 + tierBonus[tier])) + } + + sort.SliceStable(result.Artists, func(i, j int) bool { + return result.Artists[i].Score > result.Artists[j].Score + }) + + // For same-named artists in tier 0, resolve ordering via + // a targeted LB popularity lookup. + e.disambiguateSameNameArtists(q, result.Artists) + } + + // Apply tier multiplier to release group scores. + if len(result.ReleaseGroups) > 1 { + for i := range result.ReleaseGroups { + tier := rgMatchTier(q, + strings.ToLower(result.ReleaseGroups[i].Title), + strings.ToLower(result.ReleaseGroups[i].ArtistCredit)) + result.ReleaseGroups[i].Score = int(float64(result.ReleaseGroups[i].Score) * (1.0 + rgTierBonus[tier])) + } + + sort.SliceStable(result.ReleaseGroups, func(i, j int) bool { + return result.ReleaseGroups[i].Score > result.ReleaseGroups[j].Score + }) + } +} + +// disambiguateSameNameArtists resolves ordering among artists +// that share the exact same name as the query by fetching their +// LB popularity. This is a targeted micro-lookup (typically 2-6 +// MBIDs) that only fires when the index fast path couldn't +// meaningfully differentiate same-named artists. +func (e *Service) disambiguateSameNameArtists(query string, artists []MBArtist) { + // Find the contiguous block of tier-0 same-name artists at the front. + var sameNameEnd int + + for sameNameEnd < len(artists) { + if strings.ToLower(artists[sameNameEnd].Name) != query { + break + } + + sameNameEnd++ + } + + if sameNameEnd < 2 { + return // 0 or 1 same-name artists — nothing to disambiguate + } + + // Collect MBIDs for the targeted LB lookup. + mbids := make([]string, 0, sameNameEnd) + for i := range sameNameEnd { + if artists[i].MBID != "" { + mbids = append(mbids, artists[i].MBID) + } + } + + if len(mbids) < 2 { + return + } + + pop, err := e.lb.ArtistPopularity(e.ctx, mbids) + if err != nil || len(pop) == 0 { + return + } + + // Re-sort the same-name block by LB popularity descending. + sort.SliceStable(artists[:sameNameEnd], func(i, j int) bool { + return pop[artists[i].MBID].ListenCount > pop[artists[j].MBID].ListenCount + }) +} + +// nameMatchTier returns a tier value for how well a name matches +// the query. Lower is better: +// +// 0 = exact match ("the teenagers" == "the teenagers") +// 1 = name starts with query ("the teenagers" in "the teenagers feat. X") +// 2 = query is a substring ("the teenagers" in "al supersonic & the teenagers") +// 3 = no substring match (only individual words matched) +func nameMatchTier(query, name string) int { + if name == query { + return 0 + } + + if strings.HasPrefix(name, query) { + return 1 + } + + if strings.Contains(name, query) { + return 2 + } + + return 3 +} + +// rgMatchTier returns a tier for release groups considering both +// the title and artist credit. An album by "Hop Along" called +// "Painted Shut" should rank above a tribute album called +// "A Hop Along Tribute" by Various Artists. +// +// 0 = artist credit matches query exactly ("hop along" == "hop along") +// 1 = artist credit starts with or contains query +// 2 = title matches query exactly +// 3 = title starts with or contains query +// 4 = no match in either field +func rgMatchTier(query, title, artistCredit string) int { + // Artist credit match is stronger — it means the album is BY + // the searched artist, not just mentioning them in the title. + if artistCredit == query { + return 0 + } + + if strings.Contains(artistCredit, query) { + return 1 + } + + // Title match — the album name contains the query. + if title == query { + return 2 + } + + if strings.Contains(title, query) { + return 3 + } + + return 4 +} + +// 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 := blendedScoreFull(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop, personalScore(artists[i].MBID, inLib, simScores, maxSim)) + sj := blendedScoreFull(float64(artists[j].Score)/100.0, pop[artists[j].MBID], maxPop, personalScore(artists[j].MBID, inLib, simScores, maxSim)) + return si > sj + }) + + for i := range artists { + s := blendedScoreFull(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop, personalScore(artists[i].MBID, inLib, simScores, maxSim)) + 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 := blendedScoreFull(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop, personalScore(recordings[i].MBID, inLib, simScores, maxSim)) + sj := blendedScoreFull(float64(recordings[j].Score)/100.0, pop[recordings[j].MBID], maxPop, personalScore(recordings[j].MBID, inLib, simScores, maxSim)) + return si > sj + }) + + for i := range recordings { + s := blendedScoreFull(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop, personalScore(recordings[i].MBID, inLib, simScores, maxSim)) + recordings[i].Score = int(s * 100) + } +} + +// rerankReleaseGroups sorts release groups by blended score +// (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 := blendedScoreFull(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop, personalScore(rgs[i].MBID, inLib, simScores, maxSim)) + sj := blendedScoreFull(float64(rgs[j].Score)/100.0, pop[rgs[j].MBID], maxPop, personalScore(rgs[j].MBID, inLib, simScores, maxSim)) + return si > sj + }) + + for i := range rgs { + s := blendedScoreFull(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop, personalScore(rgs[i].MBID, inLib, simScores, maxSim)) + 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 { + 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 + } + + logPop := math.Log10(float64(listenCount)+1) / math.Log10(float64(effectiveMax)+1) + + 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. +func maxListenCount(pop map[string]int) int { + maxVal := 0 + + for _, v := range pop { + if v > maxVal { + maxVal = v + } + } + + 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 +// --------------------------------------------------------------------------- + +// luceneSpecialChars are characters that have special meaning in +// Lucene query syntax and must be escaped in user input. +var luceneSpecialChars = strings.NewReplacer( //nolint:gochecknoglobals + `\`, `\\`, + `+`, `\+`, + `-`, `\-`, + `!`, `\!`, + `(`, `\(`, + `)`, `\)`, + `{`, `\{`, + `}`, `\}`, + `[`, `\[`, + `]`, `\]`, + `^`, `\^`, + `"`, `\"`, + `~`, `\~`, + `*`, `\*`, + `?`, `\?`, + `:`, `\:`, + `/`, `\/`, +) + +// buildLuceneQuery converts a user's search input into a Lucene +// AND query with a wildcard on the last term for type-ahead. +// +// Examples: +// +// "radiohead" → "radiohead*" +// "the teenagers" → "the AND teenagers*" +// "florence machine" → "florence AND machine*" +// "ac/dc" → "ac\/dc*" +// +// This eliminates the common-word pollution problem: "the teenagers" +// no longer matches "The Beatles" (which only contains "the"). +// The trailing wildcard enables prefix matching as the user types. +func buildLuceneQuery(input string) string { + words := strings.Fields(strings.TrimSpace(input)) + if len(words) == 0 { + return "" + } + + // Escape special Lucene characters in each word. + for i, w := range words { + words[i] = luceneSpecialChars.Replace(w) + } + + if len(words) == 1 { + return words[0] + "*" + } + + // AND all terms, wildcard on the last (type-ahead). + var b strings.Builder + + for i, w := range words { + if i > 0 { + b.WriteString(" AND ") + } + + b.WriteString(w) + + if i == len(words)-1 { + b.WriteByte('*') + } + } + + 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 new file mode 100644 index 0000000..ad23650 --- /dev/null +++ b/backend/explore/librarymbid.go @@ -0,0 +1,147 @@ +package explore + +import ( + "strings" + + "yellowjacket/backend/database" +) + +// LibraryMBIDIndex provides fast MBID lookups against the local +// music library. Used for "In Library" badges on explore search +// results and for sharing artist images with local views. +type LibraryMBIDIndex struct { + db *database.DB +} + +// NewLibraryMBIDIndex creates a library MBID lookup service. +func NewLibraryMBIDIndex(db *database.DB) *LibraryMBIDIndex { + return &LibraryMBIDIndex{db: db} +} + +// CheckMBIDs returns which of the given MBIDs exist in the local +// library. The returned map has MBID → entity type ("artist", +// "release_group", or "recording"). +func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string { + if len(mbids) == 0 { + return nil + } + + result := make(map[string]string, len(mbids)) + + // 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 + } + + for rows.Next() { + var mbid string + if err := rows.Scan(&mbid); err == nil { + result[mbid] = te.entityType + delete(remaining, mbid) + } + } + + _ = rows.Close() + } + + return result +} + +// GetArtistMBID returns the MBID for a local artist by name, or "". +func (idx *LibraryMBIDIndex) GetArtistMBID(artistName string) string { + rows, err := idx.db.QueryContext( + "SELECT mbid FROM artists WHERE name = ? AND mbid IS NOT NULL LIMIT 1", + artistName, + ) + if err != nil { + return "" + } + + defer func() { _ = rows.Close() }() + + if rows.Next() { + var mbid string + if err := rows.Scan(&mbid); err == nil { + return mbid + } + } + + return "" +} + +// AllArtistMBIDs returns all (name, mbid) pairs for artists that +// have MBIDs. Used by the search index Tier 3 for direct matching. +func (idx *LibraryMBIDIndex) AllArtistMBIDs() map[string]string { + rows, err := idx.db.QueryContext( + "SELECT name, mbid FROM artists WHERE mbid IS NOT NULL AND mbid != ''", + ) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + result := make(map[string]string) + + for rows.Next() { + var name, mbid string + if err := rows.Scan(&name, &mbid); err == nil { + result[name] = mbid + } + } + + return result +} + +func (idx *LibraryMBIDIndex) exists(table, mbid string) bool { + //nolint:gosec // table name is hardcoded from internal callers only + rows, err := idx.db.QueryContext( + "SELECT 1 FROM "+table+" WHERE mbid = ? LIMIT 1", + mbid, + ) + if err != nil { + return false + } + + defer func() { _ = rows.Close() }() + + return rows.Next() +} diff --git a/backend/explore/listenbrainz.go b/backend/explore/listenbrainz.go new file mode 100644 index 0000000..b97b1ab --- /dev/null +++ b/backend/explore/listenbrainz.go @@ -0,0 +1,562 @@ +package explore + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "slices" + "strings" + "time" +) + +const ( + listenBrainzBaseURL = "https://api.listenbrainz.org" + lbUserAgent = "YellowJacket/dev" +) + +// ErrListenBrainzHTTP is returned when the ListenBrainz API +// responds with a non-2xx status code. +var ErrListenBrainzHTTP = errors.New("listenbrainz HTTP error") + +// ListenBrainzClient is a thin HTTP client for the ListenBrainz +// popularity and labs APIs. All requests are rate-limited via the +// shared RateLimiter and cached via the shared Cache. +type ListenBrainzClient struct { + http *http.Client + limiter *RateLimiter + cache *Cache + logger *slog.Logger +} + +// NewListenBrainzClient creates a ListenBrainz API client. +func NewListenBrainzClient( + limiter *RateLimiter, + cache *Cache, + logger *slog.Logger, +) *ListenBrainzClient { + return &ListenBrainzClient{ + http: &http.Client{Timeout: 30 * time.Second}, + limiter: limiter, + cache: cache, + logger: logger, + } +} + +// TopRecordingsForArtist returns the most-listened recordings for +// the artist identified by artistMBID. +func (c *ListenBrainzClient) TopRecordingsForArtist( + ctx context.Context, artistMBID string, +) ([]LBTopRecording, error) { + url := fmt.Sprintf( + "%s/1/popularity/top-recordings-for-artist/%s", + listenBrainzBaseURL, + artistMBID, + ) + cacheKey := "lb:top-recordings:" + artistMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []LBTopRecording + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("listenbrainz top recordings: %w", err) + } + + // The API returns snake_case JSON — unmarshal into wire type, + // then convert to the camelCase Wails type. + var wire []lbTopRecordingWire + if err := json.Unmarshal(body, &wire); err != nil { + return nil, fmt.Errorf("listenbrainz top recordings unmarshal: %w", err) + } + + const maxTopRecordings = 10 + + limit := len(wire) + if limit > maxTopRecordings { + limit = maxTopRecordings + } + + out := make([]LBTopRecording, limit) + for i := range limit { + out[i] = wire[i].toPublic() + } + + c.cacheJSON(cacheKey, out, cacheTTLSearch, artistMBID, "artist") + + return out, nil +} + +// TopReleaseGroupsForArtist returns the most-listened release groups +// for the artist identified by artistMBID. +func (c *ListenBrainzClient) TopReleaseGroupsForArtist( + ctx context.Context, artistMBID string, +) ([]LBTopReleaseGroup, error) { + url := fmt.Sprintf( + "%s/1/popularity/top-release-groups-for-artist/%s", + listenBrainzBaseURL, + artistMBID, + ) + cacheKey := "lb:top-release-groups:" + artistMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []LBTopReleaseGroup + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("listenbrainz top release groups: %w", err) + } + + var wire []lbTopReleaseGroupWire + if err := json.Unmarshal(body, &wire); err != nil { + return nil, fmt.Errorf("listenbrainz top release groups unmarshal: %w", err) + } + + const maxTopReleaseGroups = 10 + + limit := len(wire) + if limit > maxTopReleaseGroups { + limit = maxTopReleaseGroups + } + + out := make([]LBTopReleaseGroup, limit) + for i := range limit { + out[i] = wire[i].toPublic() + } + + c.cacheJSON(cacheKey, out, cacheTTLSearch, artistMBID, "artist") + + return out, nil +} + +// SimilarArtists returns artists similar to the one identified by +// artistMBID, using the ListenBrainz labs API. Returns nil, nil +// if the endpoint is unavailable (labs API may be unstable). +func (c *ListenBrainzClient) SimilarArtists( + ctx context.Context, artistMBID string, +) ([]LBSimilarArtist, error) { + url := fmt.Sprintf( + "%s/similar-artists/json?artist_mbids=%s&algorithm=%s", + labsBaseURL, + artistMBID, + labsSimilarAlgorithm, + ) + cacheKey := "lb:similar-artists:" + artistMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []LBSimilarArtist + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doGet(ctx, url) + if err != nil { + // Labs API may be unstable — log and return empty. + c.logger.Warn("listenbrainz similar artists unavailable", + "artistMBID", artistMBID, + "err", err, + ) + + return nil, nil //nolint:nilnil // graceful degradation for unstable endpoint + } + + // Labs API returns snake_case — unmarshal into wire type, + // then convert to camelCase Wails type. + var wire []lbSimilarArtistWire + if err := json.Unmarshal(body, &wire); err != nil { + return nil, fmt.Errorf("listenbrainz similar artists unmarshal: %w", err) + } + + out := make([]LBSimilarArtist, len(wire)) + for i, w := range wire { + out[i] = LBSimilarArtist{ + ArtistMBID: w.ArtistMBID, + Name: w.Name, + Score: float64(w.Score), + } + } + + // 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 +} + +// --------------------------------------------------------------------------- +// Bulk popularity lookups (POST endpoints) +// --------------------------------------------------------------------------- + +// lbPopularityResult is the response shape for all three bulk +// popularity endpoints. The JSON field names are snake_case from +// the ListenBrainz API. +type lbPopularityResult struct { + MBID string `json:"artist_mbid"` + RecordingMBID string `json:"recording_mbid"` + ReleaseGroupMBID string `json:"release_group_mbid"` + TotalListenCount *int `json:"total_listen_count"` + TotalUserCount *int `json:"total_user_count"` +} + +// ArtistPopularity fetches total listen counts for a batch of +// 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]PopularityData, error) { + if len(mbids) == 0 { + return nil, nil //nolint:nilnil + } + + url := listenBrainzBaseURL + "/1/popularity/artist" + cacheKey := "lb:pop:artist:" + hashMBIDs(mbids) + + if data, ok := c.cache.Get(cacheKey); ok { + var out map[string]PopularityData + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doPost(ctx, url, map[string][]string{ + "artist_mbids": mbids, + }) + if err != nil { + return nil, fmt.Errorf("artist popularity: %w", err) + } + + return c.parsePopularity(cacheKey, body, func(r lbPopularityResult) string { + return r.MBID + }) +} + +// RecordingPopularity fetches total listen counts for a batch of +// recording MBIDs. Returns a map[mbid]→listenCount. +func (c *ListenBrainzClient) RecordingPopularity( + ctx context.Context, mbids []string, +) (map[string]PopularityData, error) { + if len(mbids) == 0 { + return nil, nil //nolint:nilnil + } + + url := listenBrainzBaseURL + "/1/popularity/recording" + cacheKey := "lb:pop:recording:" + hashMBIDs(mbids) + + if data, ok := c.cache.Get(cacheKey); ok { + var out map[string]PopularityData + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doPost(ctx, url, map[string][]string{ + "recording_mbids": mbids, + }) + if err != nil { + return nil, fmt.Errorf("recording popularity: %w", err) + } + + return c.parsePopularity(cacheKey, body, func(r lbPopularityResult) string { + return r.RecordingMBID + }) +} + +// ReleaseGroupPopularity fetches total listen counts for a batch of +// release group MBIDs. Returns a map[mbid]→listenCount. +func (c *ListenBrainzClient) ReleaseGroupPopularity( + ctx context.Context, mbids []string, +) (map[string]PopularityData, error) { + if len(mbids) == 0 { + return nil, nil //nolint:nilnil + } + + url := listenBrainzBaseURL + "/1/popularity/release-group" + cacheKey := "lb:pop:release-group:" + hashMBIDs(mbids) + + if data, ok := c.cache.Get(cacheKey); ok { + var out map[string]PopularityData + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doPost(ctx, url, map[string][]string{ + "release_group_mbids": mbids, + }) + if err != nil { + return nil, fmt.Errorf("release group popularity: %w", err) + } + + return c.parsePopularity(cacheKey, body, func(r lbPopularityResult) string { + return r.ReleaseGroupMBID + }) +} + +// 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→PopularityData mapping, caches it, and returns it. +func (c *ListenBrainzClient) parsePopularity( + cacheKey string, + body []byte, + extractMBID func(lbPopularityResult) string, +) (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]PopularityData, len(raw)) + + for _, r := range raw { + mbid := extractMBID(r) + if mbid != "" && r.TotalListenCount != nil { + data := PopularityData{ListenCount: *r.TotalListenCount} + if r.TotalUserCount != nil { + data.ListenerCount = *r.TotalUserCount + } + + out[mbid] = data + } + } + + c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "") + + return out, nil +} + +// hashMBIDs produces a short deterministic key from a slice of +// MBIDs by sorting and hashing. Used for cache keys. +func hashMBIDs(mbids []string) string { + sorted := make([]string, len(mbids)) + copy(sorted, mbids) + slices.Sort(sorted) + + h := sha256.Sum256([]byte(strings.Join(sorted, "|"))) + + return hex.EncodeToString(h[:8]) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// doGet performs a rate-limited GET request and returns the response +// body. Non-2xx status codes are returned as errors. +func (c *ListenBrainzClient) doGet( + ctx context.Context, url string, +) ([]byte, error) { + return c.doRequest(ctx, http.MethodGet, url, nil) +} + +// doPost performs a rate-limited POST request with a JSON body and +// returns the response body. +func (c *ListenBrainzClient) doPost( + ctx context.Context, url string, body any, +) ([]byte, error) { + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal POST body: %w", err) + } + + return c.doRequest(ctx, http.MethodPost, url, payload) +} + +// doRequest is the shared HTTP helper for GET and POST. +func (c *ListenBrainzClient) doRequest( + ctx context.Context, method string, url string, body []byte, +) ([]byte, error) { + c.logger.Debug("listenbrainz rate limiter wait", "url", url) + + if err := c.limiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("rate limiter: %w", err) + } + + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", lbUserAgent) + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + c.logger.Info("listenbrainz request", + "method", method, + "url", url, + ) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + c.logger.Info("listenbrainz response", + "url", url, + "status", resp.StatusCode, + ) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf( + "%w: %d %s", ErrListenBrainzHTTP, resp.StatusCode, truncateBody(respBody), + ) + } + + return respBody, nil +} + +// cacheJSON marshals v to JSON and stores it in the cache. +func (c *ListenBrainzClient) cacheJSON( + key string, + v any, + ttl time.Duration, + mbid string, + entityType string, +) { + data, err := json.Marshal(v) + if err != nil { + c.logger.Warn("listenbrainz cache marshal error", + "key", key, + "err", err, + ) + + return + } + + c.cache.Set(key, data, ttl, mbid, entityType) +} + +// truncateBody returns the first 200 bytes of an error response +// for diagnostic logging. +func truncateBody(body []byte) string { + const maxLen = 200 + + if len(body) <= maxLen { + return string(body) + } + + return string(body[:maxLen]) + "…" +} diff --git a/backend/explore/musicbrainz.go b/backend/explore/musicbrainz.go new file mode 100644 index 0000000..25493c8 --- /dev/null +++ b/backend/explore/musicbrainz.go @@ -0,0 +1,550 @@ +package explore + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + "unicode" + + "go.uploadedlobster.com/mbtypes" + "go.uploadedlobster.com/musicbrainzws2" +) + +const ( + // cacheTTLSearch is the TTL for search results (results may shift). + cacheTTLSearch = 24 * time.Hour + // cacheTTLEntity is the TTL for lookup/browse results (entity data + // changes rarely). + cacheTTLEntity = 7 * 24 * time.Hour +) + +// MusicBrainzClient wraps the musicbrainzws2 library with a local +// response cache. Every API call checks the cache first and stores +// successful responses for future hits. +// +// A proactive rate limiter gates all outgoing requests at 1 req/sec +// to avoid triggering MusicBrainz 429 responses. The underlying +// musicbrainzws2.Client still retries on 429 as a safety net, but +// the limiter should prevent most rate-limit hits. +type MusicBrainzClient struct { + mb *musicbrainzws2.Client + cache *Cache + limiter *RateLimiter + logger *slog.Logger +} + +// NewMusicBrainzClient creates a MusicBrainz API client that caches +// responses in the given Cache. The provided rate limiter is shared +// with all other MB consumers (e.g. artist image resolution) to +// prevent concurrent bursts from triggering 429s. +func NewMusicBrainzClient(cache *Cache, limiter *RateLimiter, logger *slog.Logger) *MusicBrainzClient { + mb := musicbrainzws2.NewClient(musicbrainzws2.AppInfo{ + Name: "YellowJacket", + Version: "dev", + URL: "https://github.com/yellowjacket", + }) + + return &MusicBrainzClient{ + mb: mb, + cache: cache, + limiter: limiter, + logger: logger, + } +} + +// Close releases resources held by the underlying HTTP client. +func (c *MusicBrainzClient) Close() error { + return c.mb.Close() +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +// SearchArtists queries MusicBrainz for artists matching the given +// 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, int, error) { + cacheKey := fmt.Sprintf("mb:search:artist:%s:%d", query, limit) + + if data, ok := c.cache.Get(cacheKey); ok { + 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, 0, err + } + + c.logger.Info("musicbrainz search artists", + "query", query, + "limit", limit, + ) + + result, err := c.mb.SearchArtists(ctx, + musicbrainzws2.SearchFilter{Query: query}, + musicbrainzws2.Paginator{Limit: clampLimit(limit)}, + ) + if err != nil { + return nil, 0, err + } + + out := convertArtists(result.Artists) + + c.cacheJSON(cacheKey, mbSearchCache[MBArtist]{ + Results: out, TotalCount: result.Count, + }, cacheTTLSearch, "", "") + + 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, int, error) { + cacheKey := fmt.Sprintf("mb:search:release-group:%s:%d", query, limit) + + if data, ok := c.cache.Get(cacheKey); ok { + 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, 0, err + } + + c.logger.Info("musicbrainz search release groups", + "query", query, + "limit", limit, + ) + + result, err := c.mb.SearchReleaseGroups(ctx, + musicbrainzws2.SearchFilter{Query: query}, + musicbrainzws2.Paginator{Limit: clampLimit(limit)}, + ) + if err != nil { + return nil, 0, err + } + + out := convertReleaseGroups(result.ReleaseGroups) + + c.cacheJSON(cacheKey, mbSearchCache[MBReleaseGroup]{ + Results: out, TotalCount: result.Count, + }, cacheTTLSearch, "", "") + + 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, int, error) { + cacheKey := fmt.Sprintf("mb:search:recording:%s:%d", query, limit) + + if data, ok := c.cache.Get(cacheKey); ok { + 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, 0, err + } + + c.logger.Info("musicbrainz search recordings", + "query", query, + "limit", limit, + ) + + result, err := c.mb.SearchRecordings(ctx, + musicbrainzws2.SearchFilter{Query: query}, + musicbrainzws2.Paginator{Limit: clampLimit(limit)}, + ) + if err != nil { + return nil, 0, err + } + + out := convertRecordings(result.Recordings) + + c.cacheJSON(cacheKey, mbSearchCache[MBRecording]{ + Results: out, TotalCount: result.Count, + }, cacheTTLSearch, "", "") + + 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"` +} + +// --------------------------------------------------------------------------- +// Lookup +// --------------------------------------------------------------------------- + +// 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) { + cacheKey := "mb:lookup:artist:" + mbid + + if data, ok := c.cache.Get(cacheKey); ok { + var out MBArtist + if err := json.Unmarshal(data, &out); err == nil { + return &out, nil + } + } + + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + + c.logger.Info("musicbrainz lookup artist", "mbid", mbid) + + a, err := c.mb.LookupArtist(ctx, + mbtypes.MBID(mbid), + musicbrainzws2.IncludesFilter{Includes: []string{"release-groups"}}, + ) + if err != nil { + return nil, err + } + + out := convertArtist(a) + + 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 +} + +// LookupReleaseGroup fetches a single release group by MBID. +func (c *MusicBrainzClient) LookupReleaseGroup( + ctx context.Context, mbid string, +) (*MBReleaseGroup, error) { + cacheKey := "mb:lookup:release-group:" + mbid + + if data, ok := c.cache.Get(cacheKey); ok { + var out MBReleaseGroup + if err := json.Unmarshal(data, &out); err == nil { + return &out, nil + } + } + + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + + c.logger.Info("musicbrainz lookup release group", "mbid", mbid) + + rg, err := c.mb.LookupReleaseGroup(ctx, + mbtypes.MBID(mbid), + musicbrainzws2.IncludesFilter{Includes: []string{"artist-credits"}}, + ) + if err != nil { + return nil, err + } + + out := convertReleaseGroup(rg) + + c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "release-group") + + return &out, nil +} + +// --------------------------------------------------------------------------- +// Browse +// --------------------------------------------------------------------------- + +// BrowseReleaseGroups fetches the release groups for a given artist +// MBID. Cached for 7 days. +func (c *MusicBrainzClient) BrowseReleaseGroups( + ctx context.Context, artistMBID string, +) ([]MBReleaseGroup, error) { + cacheKey := "mb:browse:release-groups:" + artistMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []MBReleaseGroup + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + + c.logger.Info("musicbrainz browse release groups", + "artistMBID", artistMBID, + ) + + result, err := c.mb.BrowseReleaseGroups(ctx, + musicbrainzws2.ReleaseGroupFilter{ + ArtistMBID: mbtypes.MBID(artistMBID), + }, + musicbrainzws2.Paginator{Limit: musicbrainzws2.MaxLimit}, + ) + if err != nil { + return nil, err + } + + out := convertReleaseGroups(result.ReleaseGroups) + + c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist") + + return out, nil +} + +// BrowseReleases fetches the releases for a given release group +// MBID, including media/track information. Cached for 7 days. +func (c *MusicBrainzClient) BrowseReleases( + ctx context.Context, releaseGroupMBID string, +) ([]MBRelease, error) { + cacheKey := "mb:browse:releases:" + releaseGroupMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []MBRelease + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + + c.logger.Info("musicbrainz browse releases", + "releaseGroupMBID", releaseGroupMBID, + ) + + result, err := c.mb.BrowseReleases(ctx, + musicbrainzws2.ReleaseFilter{ + ReleaseGroupMBID: mbtypes.MBID(releaseGroupMBID), + Includes: []string{"recordings", "media"}, + }, + musicbrainzws2.Paginator{Limit: musicbrainzws2.MaxLimit}, + ) + if err != nil { + return nil, err + } + + out := convertReleases(result.Releases) + + c.cacheJSON(cacheKey, out, cacheTTLEntity, releaseGroupMBID, "release-group") + + return out, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// cacheJSON marshals v to JSON and stores it in the cache. +func (c *MusicBrainzClient) cacheJSON( + key string, + v any, + ttl time.Duration, + mbid string, + entityType string, +) { + data, err := json.Marshal(v) + if err != nil { + c.logger.Warn("musicbrainz cache marshal error", + "key", key, + "err", err, + ) + + return + } + + c.cache.Set(key, data, ttl, mbid, entityType) +} + +// clampLimit restricts the search limit to the MusicBrainz maximum. +func clampLimit(limit int) int { + if limit <= 0 || limit > musicbrainzws2.MaxLimit { + return musicbrainzws2.DefaultLimit + } + + return limit +} + +// --------------------------------------------------------------------------- +// Type converters (musicbrainzws2 → Wails wrapper types) +// --------------------------------------------------------------------------- + +func convertArtist(a musicbrainzws2.Artist) MBArtist { + out := MBArtist{ + MBID: string(a.ID), + Name: a.Name, + SortName: a.SortName, + Type: a.Type, + Country: string(a.CountryCode), + Disambiguation: a.Disambiguation, + Score: a.Score, + OriginalScore: a.Score, + } + + // Extract the primary English alias when the canonical name + // is non-Latin (CJK, Cyrillic, etc.). This lets the frontend + // show "Tatsuro Yamashita" alongside "山下達郎". + if !isLatinScript(a.Name) { + out.EnglishName = primaryEnglishAlias(a.Aliases) + } + + return out +} + +func convertArtists(artists []musicbrainzws2.Artist) []MBArtist { + out := make([]MBArtist, len(artists)) + for i, a := range artists { + out[i] = convertArtist(a) + } + + return out +} + +// primaryEnglishAlias returns the primary English alias name from +// a slice of aliases, or "" if none exists. +func primaryEnglishAlias(aliases []musicbrainzws2.Alias) string { + // Prefer primary English alias. + for _, a := range aliases { + if a.Locale == "en" && a.IsPrimary { + return a.Name + } + } + + // Fall back to any English alias. + for _, a := range aliases { + if a.Locale == "en" { + return a.Name + } + } + + return "" +} + +// isLatinScript returns true if the string consists primarily of +// Latin characters, digits, and common punctuation. Returns false +// for CJK, Cyrillic, Arabic, etc. +func isLatinScript(s string) bool { + for _, r := range s { + if unicode.IsLetter(r) && !unicode.In(r, unicode.Latin) { + return false + } + } + + return true +} + +func convertReleaseGroup(rg musicbrainzws2.ReleaseGroup) MBReleaseGroup { + return MBReleaseGroup{ + MBID: string(rg.ID), + Title: rg.Title, + PrimaryType: rg.PrimaryType, + SecondaryTypes: rg.SecondaryTypes, + FirstReleaseDate: rg.FirstReleaseDate.String(), + ArtistCredit: rg.ArtistCredit.String(), + Score: rg.Score, + } +} + +func convertReleaseGroups(rgs []musicbrainzws2.ReleaseGroup) []MBReleaseGroup { + out := make([]MBReleaseGroup, len(rgs)) + for i, rg := range rgs { + out[i] = convertReleaseGroup(rg) + } + + return out +} + +func convertRelease(r musicbrainzws2.Release) MBRelease { + rel := MBRelease{ + MBID: string(r.ID), + Title: r.Title, + Date: r.Date.String(), + Country: string(r.CountryCode), + Status: r.Status, + } + + 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: recordingMBID, + }) + } + } + + return rel +} + +func convertReleases(releases []musicbrainzws2.Release) []MBRelease { + out := make([]MBRelease, len(releases)) + for i, r := range releases { + out[i] = convertRelease(r) + } + + return out +} + +func convertRecording(r musicbrainzws2.Recording) MBRecording { + return MBRecording{ + MBID: string(r.ID), + Title: r.Title, + Length: int(r.Length.Milliseconds()), + ArtistCredit: r.ArtistCredit.String(), + Score: r.Score, + } +} + +func convertRecordings(recordings []musicbrainzws2.Recording) []MBRecording { + out := make([]MBRecording, len(recordings)) + for i, r := range recordings { + out[i] = convertRecording(r) + } + + return out +} diff --git a/backend/explore/ratelimiter.go b/backend/explore/ratelimiter.go new file mode 100644 index 0000000..d9a06ad --- /dev/null +++ b/backend/explore/ratelimiter.go @@ -0,0 +1,64 @@ +// Package explore provides MusicBrainz and ListenBrainz API clients +// with rate-limited HTTP access and a SQLite response cache. +package explore + +import ( + "context" + "time" + + "golang.org/x/time/rate" +) + +// RateLimiter enforces a maximum request rate using a token bucket. +// MusicBrainz requires ≤1 request per second and rejects ALL +// requests (not just excess) when the rate is exceeded, so callers +// block proactively via Wait rather than retrying reactively. +// +// RateLimiter is safe for concurrent use. +type RateLimiter struct { + limiter *rate.Limiter +} + +// NewRateLimiter returns a rate limiter that allows exactly one +// request per second with a burst size of 1. The first call to +// Wait returns immediately; subsequent calls block until the next +// token is available. +func NewRateLimiter() *RateLimiter { + return &RateLimiter{ + limiter: rate.NewLimiter(rate.Every(time.Second), 1), + } +} + +// NewRateLimiterN returns a rate limiter that allows n requests +// per second with a burst of n. Used for background tasks like +// index building where a higher rate is acceptable. +func NewRateLimiterN(n int) *RateLimiter { + return &RateLimiter{ + limiter: rate.NewLimiter(rate.Limit(n), n), + } +} + +// NewRateLimiterF returns a rate limiter that allows f requests +// per second with a burst of 1. +func NewRateLimiterF(f float64) *RateLimiter { + return &RateLimiter{ + limiter: rate.NewLimiter(rate.Limit(f), 1), + } +} + +// NewRateLimiterBurst returns a rate limiter that allows n requests +// per second with a burst size of b. The burst allows short spikes +// (e.g. 3 concurrent search calls) without queueing, while still +// limiting sustained throughput. +func NewRateLimiterBurst(n, b int) *RateLimiter { + return &RateLimiter{ + limiter: rate.NewLimiter(rate.Limit(n), b), + } +} + +// Wait blocks until the rate limiter allows the caller to proceed +// or the context is cancelled. Returns ctx.Err() if the context +// expires before a token becomes available. +func (r *RateLimiter) Wait(ctx context.Context) error { + return r.limiter.Wait(ctx) +} diff --git a/backend/explore/ratelimiter_test.go b/backend/explore/ratelimiter_test.go new file mode 100644 index 0000000..6c80544 --- /dev/null +++ b/backend/explore/ratelimiter_test.go @@ -0,0 +1,62 @@ +package explore + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestRateLimiterBurst(t *testing.T) { + rl := NewRateLimiter() + ctx := context.Background() + + const n = 5 + + start := time.Now() + + for i := range n { + if err := rl.Wait(ctx); err != nil { + t.Fatalf("Wait %d: %v", i, err) + } + } + + elapsed := time.Since(start) + + // First request is immediate; 4 more at 1/sec = ≥4s total. + if elapsed < 4*time.Second { + t.Errorf( + "elapsed %v, want ≥ 4s (rate limiter too fast)", elapsed, + ) + } + + // Generous upper bound to avoid CI flakes. + if elapsed > 7*time.Second { + t.Errorf( + "elapsed %v, want ≤ 7s (rate limiter too slow)", elapsed, + ) + } +} + +func TestRateLimiterContextCancel(t *testing.T) { + t.Parallel() + + rl := NewRateLimiter() + + // Drain the initial token so the next Wait must block. + if err := rl.Wait(context.Background()); err != nil { + t.Fatalf("drain token: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + err := rl.Wait(ctx) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } + + if !errors.Is(err, context.Canceled) { + t.Errorf("error = %v, want context.Canceled", err) + } +} diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go new file mode 100644 index 0000000..23ef848 --- /dev/null +++ b/backend/explore/searchindex.go @@ -0,0 +1,3036 @@ +package explore + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "math" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "yellowjacket/backend/database" + "yellowjacket/backend/events" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// Index build parameters. +const ( + // indexTier1Interval is the minimum time between Tier 1 + // (sitewide top lists) refreshes. Cheap — 12 API calls. + indexTier1Interval = 7 * 24 * time.Hour + + // indexTier2Interval is the minimum time between Tier 2/4 + // (discography) refreshes. Incremental — only new artists. + indexTier2Interval = 30 * 24 * time.Hour + + // indexTopArtists is the number of artists to fetch per range + // from the LB sitewide endpoint. + indexTopArtists = 1000 + + // indexMaxRGs is the ceiling for release groups per artist. + // Top-popularity artists get their full discography. + indexMaxRGs = 50 + + // indexMinRGs is the floor for release groups per artist. + // Even the least popular indexed artist gets a couple of albums. + indexMinRGs = 2 + + // indexMaxRecs is the ceiling for recordings per artist. + indexMaxRecs = 200 + + // indexMinRecs is the floor for recordings per artist. + indexMinRecs = 5 + + // indexMinPopularity is the minimum listen count for an entry + // to be indexed. Cuts noise from long-tail entries. + indexMinPopularity = 50 + + // indexBatchSize is the number of rows per INSERT transaction. + indexBatchSize = 100 + + // indexerRate is the requests-per-second for the background + // indexer's dedicated rate limiter (LB allows 30/10s). + indexerRate = 3 + + // indexProgressInterval is how often to log progress. + indexProgressInterval = 100 + + // 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. + // 0.3 means an artist with 1/10th the listens of the max gets + // ~50% of the budget, not 10%. + indexPopularityExponent = 0.3 + + // labsBaseURL is the base URL for the ListenBrainz labs API. + labsBaseURL = "https://labs.api.listenbrainz.org" + + // labsSimilarAlgorithm is the algorithm parameter for the + // similar-artists endpoint. + labsSimilarAlgorithm = "session_based_days_7500_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30" + + // 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"` + 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 { + ArtistMBID string `json:"artist_mbid"` + ArtistName string `json:"artist_name"` + ListenCount int `json:"listen_count"` +} + +// SearchIndex maintains a local SQLite FTS5 index of popular +// albums and tracks from ListenBrainz. The index is built in the +// background on startup across multiple tiers: +// +// - Tier 1: sitewide top lists (instant, <5s) +// - Tier 2: sitewide artists' full discographies (background, ~16min) +// - Tier 3: library artists' full discographies (background, ~4min) +// - Tier 4: similar artists to library artists (background, ~24min) +// - Tier 5: organic growth from user browsing (ongoing, free) +type SearchIndex struct { + db *database.DB + lb *ListenBrainzClient + artistImg *ArtistImageProvider + logger *slog.Logger + runtimeCtx context.Context // Wails runtime context for event emission + + cancel context.CancelFunc + done chan 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 +// database. Call StartBuild to kick off the background populate. +func NewSearchIndex( + db *database.DB, + lb *ListenBrainzClient, + artistImg *ArtistImageProvider, + logger *slog.Logger, +) *SearchIndex { + return &SearchIndex{ + db: db, + lb: lb, + artistImg: artistImg, + logger: logger, + } +} + +// SetContext injects the Wails runtime context for event emission. +func (si *SearchIndex) SetContext(ctx context.Context) { + si.runtimeCtx = ctx + + // Initialize buildStatus with empty (non-nil) tiers so the + // frontend always receives a valid array, not JSON null. + si.mu.Lock() + if si.buildStatus.Tiers == nil { + si.buildStatus.Tiers = []TierStatus{} + } + si.mu.Unlock() + + // Load current row counts + last-built timestamp from DB. + 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. +// Just finds library artists with MBIDs missing from the index and +// fetches their discographies + images. +func (si *SearchIndex) IndexNewArtists(ctx context.Context) { + si.mu.Lock() + if si.cancel != nil { + // Full build already running — it will pick up new artists. + si.mu.Unlock() + + return + } + + si.done = make(chan struct{}) + si.mu.Unlock() + + buildCtx, cancel := context.WithCancel(ctx) + + si.mu.Lock() + si.cancel = cancel + si.mu.Unlock() + + go func() { + defer func() { + si.mu.Lock() + si.cancel = nil + si.mu.Unlock() + + close(si.done) + + // Mark ready if we indexed anything, so search works + // while the full tier build is pending. + si.MarkReadyIfPopulated() + }() + + si.indexNewLibraryArtists(buildCtx) + }() +} + +// indexNewLibraryArtists finds library artists with MBIDs that are not +// in the index and fetches their discographies. +func (si *SearchIndex) indexNewLibraryArtists(ctx context.Context) { + indexed := si.indexedArtistMBIDs() + libraryMBIDs := si.getLibraryArtistMBIDs() + + var newArtists []lbSitewideArtist + + for _, mbid := range libraryMBIDs { + if !indexed[mbid] { + // Look up the artist name from the DB. + var name string + + rows, err := si.db.QueryContext( + "SELECT name FROM artists WHERE mbid = ? LIMIT 1", mbid, + ) + if err != nil { + continue + } + + if !rows.Next() { + _ = rows.Close() + + continue + } + + if err := rows.Scan(&name); err != nil { + _ = rows.Close() + + continue + } + + _ = rows.Close() + + newArtists = append(newArtists, lbSitewideArtist{ + ArtistMBID: mbid, + ArtistName: name, + }) + } + } + + if len(newArtists) == 0 { + si.logger.Info("search index: no new library artists to index") + + return + } + + si.logger.Info("search index: indexing new library artists", + "count", len(newArtists), + ) + + indexLimiter := NewRateLimiterN(indexerRate) + indexLB := NewListenBrainzClient(indexLimiter, si.lb.cache, si.logger.WithGroup("indexer")) + + si.indexArtistDiscographies(ctx, indexLB, newArtists, "new-artists", true) + + si.logger.Info("search index: new library artists indexed", + "count", len(newArtists), + ) +} + +// StartBuild launches the background index build goroutine. +// Returns immediately. +func (si *SearchIndex) StartBuild(ctx context.Context) { + si.mu.Lock() + // Don't start if already running. + if si.cancel != nil { + si.mu.Unlock() + + return + } + + si.done = make(chan struct{}) + si.mu.Unlock() + + buildCtx, cancel := context.WithCancel(ctx) + + si.mu.Lock() + si.cancel = cancel + si.mu.Unlock() + + go func() { + defer func() { + si.mu.Lock() + si.cancel = nil + si.mu.Unlock() + + close(si.done) + }() + + si.build(buildCtx) + }() +} + +// StopBuild cancels an in-flight build and waits for it to finish. +// Safe to call even if no build is running. +func (si *SearchIndex) StopBuild() { + si.mu.RLock() + cancel := si.cancel + done := si.done + si.mu.RUnlock() + + if cancel != nil { + cancel() + } + + if done != nil { + <-done + } +} + +// WaitForIdle blocks until no build or indexing goroutine is running. +// Unlike StopBuild, this does NOT cancel a running build. +func (si *SearchIndex) WaitForIdle() { + si.mu.RLock() + done := si.done + si.mu.RUnlock() + + if done != nil { + <-done + } +} + +// IsReady returns true once the index has been built at least once. +func (si *SearchIndex) IsReady() bool { + si.mu.RLock() + defer si.mu.RUnlock() + + return si.ready +} + +// GetIndexStatus returns the current index build status for the UI. +// Entirely in-memory — no DB queries — to avoid blocking the Wails +// UI thread when the index build holds a write lock. +func (si *SearchIndex) GetIndexStatus() IndexStatus { + si.mu.RLock() + status := si.buildStatus + status.Ready = si.ready + status.Building = si.cancel != nil + si.mu.RUnlock() + + return status +} + +// refreshStatusCounts updates the row counts and last-built timestamp +// in buildStatus from the DB. Called between tiers when the DB is idle. +func (si *SearchIndex) refreshStatusCounts() { + var artists, recordings, rgs int + + rows, err := si.db.QueryContext(` + SELECT entity_type, COUNT(*) FROM explore_index GROUP BY entity_type + `) + if err == nil { + defer func() { _ = rows.Close() }() + + for rows.Next() { + var et 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 { + if mbid == "" { + return 0 + } + + rows, err := si.db.QueryContext( + "SELECT popularity FROM explore_index WHERE mbid = ? LIMIT 1", + mbid, + ) + if err != nil { + return 0 + } + + defer func() { _ = rows.Close() }() + + if rows.Next() { + var pop int + if err := rows.Scan(&pop); err == nil { + return pop + } + } + + return 0 +} + +// PopularityBatchResult contains popularity, listener count, library +// status, and similarity scores for a batch of MBIDs. +type PopularityBatchResult struct { + Popularity map[string]int + ListenerCount map[string]int + InLibrary map[string]bool + SimilarityScores map[string]int // max similarity score (0 = not similar) +} + +// GetPopularityBatch returns popularity (listen count) and library +// status for multiple MBIDs in a single query. +func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult { + if len(mbids) == 0 { + return nil + } + + placeholders := make([]string, len(mbids)) + args := make([]any, len(mbids)) + + for i, m := range mbids { + placeholders[i] = "?" + args[i] = m + } + + query := "SELECT mbid, popularity, listener_count, in_library FROM explore_index WHERE mbid IN (" + + strings.Join(placeholders, ",") + ")" + + rows, err := si.db.QueryContext(query, args...) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + result := &PopularityBatchResult{ + Popularity: make(map[string]int, len(mbids)), + ListenerCount: make(map[string]int), + InLibrary: make(map[string]bool), + SimilarityScores: make(map[string]int), + } + + for rows.Next() { + var mbid string + var pop int + var listeners int + var inLib int + + 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 +} + +// IsInLibrary returns whether the given MBID is marked as in the +// user's local library in the search index. +func (si *SearchIndex) IsInLibrary(mbid string) bool { + if mbid == "" { + return false + } + + rows, err := si.db.QueryContext( + "SELECT in_library FROM explore_index WHERE mbid = ? AND in_library = 1 LIMIT 1", + mbid, + ) + if err != nil { + return false + } + + defer func() { _ = rows.Close() }() + + return rows.Next() +} + +// LookupArtistByMBID reads a single artist row from the index, including +// all metadata fields (type, country, disambiguation, sort_name). +// Returns nil if the artist isn't indexed. +func (si *SearchIndex) LookupArtistByMBID(mbid string) *SearchIndexResult { + rows, err := si.db.QueryContext( + `SELECT title, artist_name, artist_mbid, popularity, listener_count, + artist_type, country, disambiguation, sort_name, aliases, + in_library, is_similar, COALESCE(local_artist_id, 0) + FROM explore_index + WHERE mbid = ? AND entity_type = '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. +func (si *SearchIndex) AddFromCache(artistName, artistMBID string, rgs []MBReleaseGroup) { + if len(rgs) == 0 { + return + } + + entries := make([]SearchIndexResult, 0, len(rgs)+1) + + // Add the artist itself. + entries = append(entries, SearchIndexResult{ + EntityType: "artist", + MBID: artistMBID, + Title: artistName, + ArtistName: artistName, + ArtistMBID: artistMBID, + Popularity: 0, // Unknown from this path. + }) + + for _, rg := range rgs { + entries = append(entries, SearchIndexResult{ + EntityType: "release_group", + MBID: rg.MBID, + Title: rg.Title, + ArtistName: artistName, + ArtistMBID: artistMBID, + Popularity: 0, + PrimaryType: rg.PrimaryType, + SecondaryTypes: strings.Join(rg.SecondaryTypes, ","), + ReleaseDate: rg.FirstReleaseDate, + }) + } + + si.upsertBatch(entries) + + si.logger.Debug("search index: organic add", + "artist", artistName, + "releaseGroups", len(rgs), + ) +} + +// Search queries the local FTS5 index and returns matches sorted +// by popularity descending. +// ExactMatches returns index rows whose normalized title (or artist +// name) exactly equals the given query. Used by the top-results +// intent pipeline as a dedicated retrieval source — exact matches +// 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 + } + + ftsQuery := buildFTSQuery(query) + if ftsQuery == "" { + return nil + } + + rows, err := si.db.QueryContext(` + SELECT i.entity_type, i.mbid, i.title, i.artist_name, + 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 ? + ORDER BY bm25(explore_index_fts, 3.0, 1.0, 0.5) + - (ln(i.popularity + 1) * 1.5) + - (i.in_library * 3.0) + - (i.is_similar * 1.5) + LIMIT ? + `, ftsQuery, limit) + if err != nil { + si.logger.Warn("search index query error", + "query", query, + "ftsQuery", ftsQuery, + "error", err, + ) + + return nil + } + + defer func() { _ = rows.Close() }() + + var results []SearchIndexResult + + 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 { + si.logger.Warn("search index scan error", "error", err) + + continue + } + + results = append(results, r) + } + + return results +} + +// --------------------------------------------------------------------------- +// FTS query building +// --------------------------------------------------------------------------- + +func buildFTSQuery(query string) string { + words := splitWords(query) + if len(words) == 0 { + return "" + } + + var b strings.Builder + + for i, w := range words { + if i > 0 { + b.WriteByte(' ') + } + + b.WriteString(w) + b.WriteByte('*') + } + + return b.String() +} + +func splitWords(s string) []string { + var words []string + + current := "" + + for _, r := range s { + if isWordChar(r) { + current += string(r) + } else if current != "" { + words = append(words, current) + current = "" + } + } + + if current != "" { + words = append(words, current) + } + + return words +} + +func isWordChar(r rune) bool { + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r >= 0x80 +} + +// --------------------------------------------------------------------------- +// Background build — orchestrator +// --------------------------------------------------------------------------- + +func (si *SearchIndex) build(ctx context.Context) { + start := time.Now() + + 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() + + indexLimiter := NewRateLimiterN(indexerRate) + indexLB := NewListenBrainzClient(indexLimiter, si.lb.cache, si.logger.WithGroup("indexer")) + + // Tier 1: sitewide instant — refresh weekly (12 calls, <5s). + tier1Fresh := si.isMetaFresh("tier1_built", indexTier1Interval) + + var sitewideArtists []lbSitewideArtist + + 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 { + return + } + + 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. + // Only fetch discographies for artists not already indexed. + // Each tier's timestamp is tracked independently so progress + // survives app restarts mid-build. + tier2Fresh := si.isMetaFresh("tier2_built", indexTier2Interval) + 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 { + indexed := si.indexedArtistMBIDs() + + var libraryMBIDs []string + + // 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) + + si.logger.Info("search index: Tier 2 starting", + "total", len(sitewideArtists), + "alreadyIndexed", len(sitewideArtists)-len(newSitewide), + "new", len(newSitewide), + ) + + 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) + + if ctx.Err() != nil { + return + } + + 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. + libraryMBIDs = si.getLibraryArtistMBIDs() + } + + indexed = si.indexedArtistMBIDs() + si.setTierStatus("Similar Artists", "running", len(libraryMBIDs), 0) + si.buildTier4Similar(ctx, indexLB, libraryMBIDs, indexed) + + if ctx.Err() != nil { + return + } + + 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)) +} + +// --------------------------------------------------------------------------- +// Tier 1: sitewide instant +// --------------------------------------------------------------------------- + +// buildTier1Sitewide fetches top artists, recordings, and release +// groups across all time ranges and inserts them. Returns the +// deduplicated artist list for Tier 2. +func (si *SearchIndex) buildTier1Sitewide( + ctx context.Context, + lb *ListenBrainzClient, +) []lbSitewideArtist { + ranges := []string{"all_time", "this_year", "this_month", "this_week"} + artistMap := make(map[string]lbSitewideArtist) + + for _, r := range ranges { + if ctx.Err() != nil { + break + } + + // Artists. + artists, err := si.fetchSitewideArtists(ctx, r) + if err != nil { + si.logger.Warn("search index: sitewide artists failed", "range", r, "error", err) + + continue + } + + for _, a := range artists { + if _, exists := artistMap[a.ArtistMBID]; !exists { + artistMap[a.ArtistMBID] = a + } + } + + // Recordings. + recs := si.fetchSitewideRecordings(ctx, lb, r) + si.upsertSearchResults(recs) + + // Release groups. + rgs := si.fetchSitewideReleaseGroups(ctx, lb, r) + si.upsertSearchResults(rgs) + } + + // Insert all artists and track max popularity. + artists := make([]lbSitewideArtist, 0, len(artistMap)) + + maxL := 0 + + for _, a := range artistMap { + artists = append(artists, a) + + if a.ListenCount > maxL { + maxL = a.ListenCount + } + } + + si.mu.Lock() + si.maxListens = maxL + si.mu.Unlock() + + si.upsertArtists(artists) + + si.logger.Info("search index: Tier 1 indexed", + "artists", len(artists), + ) + + return artists +} + +func (si *SearchIndex) fetchSitewideArtists( + ctx context.Context, timeRange string, +) ([]lbSitewideArtist, error) { + url := fmt.Sprintf( + "%s/1/stats/sitewide/artists?count=%d&range=%s", + listenBrainzBaseURL, indexTopArtists, timeRange, + ) + + req, err := newLBRequest(ctx, url) + if err != nil { + return nil, err + } + + resp, err := si.lb.http.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + var envelope struct { + Payload struct { + Artists []lbSitewideArtist `json:"artists"` + } `json:"payload"` + } + + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, err + } + + return envelope.Payload.Artists, nil +} + +func (si *SearchIndex) fetchSitewideRecordings( + ctx context.Context, lb *ListenBrainzClient, timeRange string, +) []SearchIndexResult { + url := fmt.Sprintf( + "%s/1/stats/sitewide/recordings?count=%d&range=%s", + listenBrainzBaseURL, indexTopArtists, timeRange, + ) + + body, err := lb.doGet(ctx, url) + if err != nil { + si.logger.Warn("search index: sitewide recordings failed", + "range", timeRange, "error", err, + ) + + return nil + } + + var envelope struct { + Payload struct { + Recordings []struct { + RecordingMBID string `json:"recording_mbid"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + ArtistMBIDs []string `json:"artist_mbids"` + ListenCount int `json:"listen_count"` + } `json:"recordings"` + } `json:"payload"` + } + + if err := json.Unmarshal(body, &envelope); err != nil { + si.logger.Warn("search index: sitewide recordings unmarshal", + "range", timeRange, "error", err, + ) + + return nil + } + + var results []SearchIndexResult + + for _, r := range envelope.Payload.Recordings { + if r.ListenCount < indexMinPopularity { + continue + } + + artistMBID := "" + if len(r.ArtistMBIDs) > 0 { + artistMBID = r.ArtistMBIDs[0] + } + + results = append(results, SearchIndexResult{ + EntityType: "recording", + MBID: r.RecordingMBID, + Title: r.TrackName, + ArtistName: r.ArtistName, + ArtistMBID: artistMBID, + // Popularity intentionally 0 — backfilled by Tier 5 with the + // uncapped total_listen_count from the popularity API. + }) + } + + return results +} + +func (si *SearchIndex) fetchSitewideReleaseGroups( + ctx context.Context, lb *ListenBrainzClient, timeRange string, +) []SearchIndexResult { + url := fmt.Sprintf( + "%s/1/stats/sitewide/release-groups?count=%d&range=%s", + listenBrainzBaseURL, indexTopArtists, timeRange, + ) + + body, err := lb.doGet(ctx, url) + if err != nil { + si.logger.Warn("search index: sitewide release groups failed", + "range", timeRange, "error", err, + ) + + return nil + } + + var envelope struct { + Payload struct { + ReleaseGroups []struct { + ReleaseGroupMBID string `json:"release_group_mbid"` + ReleaseGroupName string `json:"release_group_name"` + ArtistName string `json:"artist_name"` + ArtistMBIDs []string `json:"artist_mbids"` + ListenCount int `json:"listen_count"` + } `json:"release_groups"` + } `json:"payload"` + } + + if err := json.Unmarshal(body, &envelope); err != nil { + si.logger.Warn("search index: sitewide release groups unmarshal", + "range", timeRange, "error", err, + ) + + return nil + } + + var results []SearchIndexResult + + for _, r := range envelope.Payload.ReleaseGroups { + if r.ListenCount < indexMinPopularity { + continue + } + + artistMBID := "" + if len(r.ArtistMBIDs) > 0 { + artistMBID = r.ArtistMBIDs[0] + } + + results = append(results, SearchIndexResult{ + EntityType: "release_group", + MBID: r.ReleaseGroupMBID, + Title: r.ReleaseGroupName, + ArtistName: r.ArtistName, + ArtistMBID: artistMBID, + // Popularity intentionally 0 — backfilled by Tier 5. + }) + } + + return results +} + +// --------------------------------------------------------------------------- +// Tier 2: sitewide artists' full discographies +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Tier 3: library artists' full discographies +// --------------------------------------------------------------------------- + +// buildTier3Library matches local library artist names against +// sitewide artists by name to get MBIDs, then indexes their +// discographies. Returns the resolved MBIDs for Tier 4. +func (si *SearchIndex) buildTier3Library( + ctx context.Context, + lb *ListenBrainzClient, + sitewideArtists []lbSitewideArtist, + indexed map[string]bool, +) []string { + // Build a name→artist map from sitewide (lowercased). + nameMap := make(map[string]lbSitewideArtist, len(sitewideArtists)) + for _, a := range sitewideArtists { + nameMap[strings.ToLower(a.ArtistName)] = a + } + + // Also build from existing index entries (catches organic adds). + rows, err := si.db.QueryContext(` + SELECT DISTINCT artist_name, artist_mbid + FROM explore_index + WHERE entity_type = 'artist' AND artist_mbid != '' + `) + if err == nil { + defer func() { _ = rows.Close() }() + + for rows.Next() { + var name, mbid string + if err := rows.Scan(&name, &mbid); err == nil { + lower := strings.ToLower(name) + if _, exists := nameMap[lower]; !exists { + nameMap[lower] = lbSitewideArtist{ + ArtistMBID: mbid, + ArtistName: name, + } + } + } + } + } + + // Read local library artists — prefer direct MBIDs from tags, + // fall back to name matching against the sitewide/index map. + libRows, err := si.db.QueryContext( + "SELECT DISTINCT name, mbid FROM artists", + ) + if err != nil { + si.logger.Warn("search index: library artists query failed", "error", err) + + return nil + } + + defer func() { _ = libRows.Close() }() + + var matched []lbSitewideArtist + + var resolvedMBIDs []string + + for libRows.Next() { + var name string + + var mbidPtr *string + + if err := libRows.Scan(&name, &mbidPtr); err != nil { + continue + } + + // Direct MBID from tags — most reliable. + if mbidPtr != nil && *mbidPtr != "" { + mbid := *mbidPtr + resolvedMBIDs = append(resolvedMBIDs, mbid) + + if !indexed[mbid] { + matched = append(matched, lbSitewideArtist{ + ArtistMBID: mbid, + ArtistName: name, + }) + } + + continue + } + + // Fall back to name matching. + normalized := strings.ToLower(name) + if idx := strings.Index(normalized, " feat."); idx >= 0 { + normalized = normalized[:idx] + } + + if idx := strings.Index(normalized, " ft."); idx >= 0 { + normalized = normalized[:idx] + } + + normalized = strings.TrimSpace(normalized) + + if a, ok := nameMap[normalized]; ok { + resolvedMBIDs = append(resolvedMBIDs, a.ArtistMBID) + + if !indexed[a.ArtistMBID] { + matched = append(matched, a) + } + } + } + + if len(matched) > 0 { + si.indexArtistDiscographies(ctx, lb, matched, "Tier 3", true) + + // Mark all Tier 3 entries as in_library. + si.markInLibrary(matched) + } + + si.logger.Info("search index: Tier 3 matched", + "libraryArtists", len(resolvedMBIDs), + "newToIndex", len(matched), + ) + + return resolvedMBIDs +} + +// --------------------------------------------------------------------------- +// Tier 4: similar artists to library artists +// --------------------------------------------------------------------------- + +func (si *SearchIndex) buildTier4Similar( + ctx context.Context, + lb *ListenBrainzClient, + libraryMBIDs []string, + indexed map[string]bool, +) { + if len(libraryMBIDs) == 0 { + return + } + + // 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) + + for i := 0; i < len(libraryMBIDs); i += similarArtistsBatchSize { + if ctx.Err() != nil { + break + } + + end := i + similarArtistsBatchSize + if end > len(libraryMBIDs) { + end = len(libraryMBIDs) + } + + batch := libraryMBIDs[i:end] + grouped := si.fetchSimilarArtistsBatch(ctx, lb, batch) + + // Persist similarity relationships per seed. + for _, seedMBID := range batch { + similar := grouped[seedMBID] + si.storeSimilarArtists(seedMBID, similar) + + for _, s := range similar { + if !indexed[s.ArtistMBID] { + if _, exists := newArtistMap[s.ArtistMBID]; !exists { + newArtistMap[s.ArtistMBID] = lbSitewideArtist{ + ArtistMBID: s.ArtistMBID, + ArtistName: s.Name, + } + } + } + } + } + + si.logger.Info("search index: Tier 4 similar batch complete", + "batch", (i/similarArtistsBatchSize)+1, + "totalBatches", (len(libraryMBIDs)+similarArtistsBatchSize-1)/similarArtistsBatchSize, + "processed", end, + ) + } + + if len(newArtistMap) == 0 { + return + } + + newArtists := make([]lbSitewideArtist, 0, len(newArtistMap)) + for _, a := range newArtistMap { + newArtists = append(newArtists, a) + } + + si.logger.Info("search index: Tier 4 discovered", + "newArtists", len(newArtists), + ) + + // 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"` + ReferenceMBID string `json:"reference_mbid"` // which seed artist this result belongs to +} + +// 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 + ) + + 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) + } + + wg.Wait() + + return grouped +} + +// --------------------------------------------------------------------------- +// Shared: index artist discographies +// --------------------------------------------------------------------------- + +// indexArtistDiscographies fetches top release groups and recordings +// for each artist and inserts them into the index. Used by Tiers 2-4. +func (si *SearchIndex) indexArtistDiscographies( + ctx context.Context, + 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 + + var completed atomic.Int32 + + for _, a := range artists { + if ctx.Err() != nil { + break + } + + sem <- struct{}{} + + wg.Add(1) + + go func(artist lbSitewideArtist) { + defer func() { + <-sem + wg.Done() + }() + + 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, + "completed", n, + "total", len(artists), + "pct", fmt.Sprintf("%.0f%%", float64(n)/float64(len(artists))*100), + ) + } + }(a) + } + + wg.Wait() + + si.logger.Info("search index: discographies indexed", + "tier", tier, + "artists", len(artists), + ) +} + +// 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, forceMax) + + // Run LB discography fetches and MB artist image resolution + // concurrently — they use different rate limiters so they + // don't block each other. + var ( + rgs []SearchIndexResult + recs []SearchIndexResult + wg sync.WaitGroup + ) + + // LB pipeline: top release groups + top recordings. + wg.Add(1) + + go func() { + defer wg.Done() + + rgs = si.fetchTopReleaseGroups(ctx, lb, artist, rgLimit) + recs = si.fetchTopRecordings(ctx, lb, artist, recLimit) + }() + + // MB pipeline: resolve + cache artist image (uses MB rate limiter). + wg.Add(1) + + go func() { + defer wg.Done() + + if si.artistImg != nil { + si.artistImg.GetArtistImage(artist.ArtistMBID) + } + }() + + wg.Wait() + + // Write the artist entry into the index so indexedArtistMBIDs() + // recognises this artist as processed on subsequent builds. + // 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 { + 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. + all := make([]SearchIndexResult, 0, len(rgs)+len(recs)) + all = append(all, rgs...) + all = append(all, recs...) + + for i := 0; i < len(all); i += indexBatchSize { + end := i + indexBatchSize + if end > len(all) { + end = len(all) + } + + si.upsertBatch(all[i:end]) + } +} + +func (si *SearchIndex) fetchTopReleaseGroups( + ctx context.Context, + lb *ListenBrainzClient, + artist lbSitewideArtist, + maxCount int, +) []SearchIndexResult { + url := fmt.Sprintf( + "%s/1/popularity/top-release-groups-for-artist/%s", + listenBrainzBaseURL, artist.ArtistMBID, + ) + + body, err := lb.doGet(ctx, url) + if err != nil { + si.logger.Debug("search index: top RGs failed", + "artist", artist.ArtistName, + "error", err, + ) + + return nil + } + + var raw []struct { + ReleaseGroupMBID string `json:"release_group_mbid"` + TotalListenCount int `json:"total_listen_count"` + ReleaseGroup struct { + Name string `json:"name"` + Type string `json:"type"` + Date string `json:"date"` + CAAReleaseMBID string `json:"caa_release_mbid"` + } `json:"release_group"` + Artist struct { + Artists []struct { + ArtistMBID string `json:"artist_mbid"` + Name string `json:"name"` + } `json:"artists"` + } `json:"artist"` + } + + if err := json.Unmarshal(body, &raw); err != nil { + return nil + } + + limit := maxCount + if limit > len(raw) { + limit = len(raw) + } + + results := make([]SearchIndexResult, 0, limit) + + for _, r := range raw[:limit] { + if r.TotalListenCount < indexMinPopularity { + continue + } + + artistName := artist.ArtistName + artistMBID := artist.ArtistMBID + + if len(r.Artist.Artists) > 0 { + artistName = r.Artist.Artists[0].Name + artistMBID = r.Artist.Artists[0].ArtistMBID + } + + results = append(results, SearchIndexResult{ + EntityType: "release_group", + MBID: r.ReleaseGroupMBID, + Title: r.ReleaseGroup.Name, + ArtistName: artistName, + ArtistMBID: artistMBID, + Popularity: r.TotalListenCount, + PrimaryType: r.ReleaseGroup.Type, + ReleaseDate: r.ReleaseGroup.Date, + CAAReleaseMBID: r.ReleaseGroup.CAAReleaseMBID, + }) + } + + return results +} + +func (si *SearchIndex) fetchTopRecordings( + ctx context.Context, + lb *ListenBrainzClient, + artist lbSitewideArtist, + maxCount int, +) []SearchIndexResult { + url := fmt.Sprintf( + "%s/1/popularity/top-recordings-for-artist/%s", + listenBrainzBaseURL, artist.ArtistMBID, + ) + + body, err := lb.doGet(ctx, url) + if err != nil { + return nil + } + + var raw []lbTopRecordingWire + if err := json.Unmarshal(body, &raw); err != nil { + return nil + } + + limit := maxCount + if limit > len(raw) { + limit = len(raw) + } + + results := make([]SearchIndexResult, 0, limit) + + for _, r := range raw[:limit] { + if r.TotalListenCount < indexMinPopularity { + continue + } + + results = append(results, SearchIndexResult{ + EntityType: "recording", + MBID: r.RecordingMBID, + Title: r.RecordingName, + ArtistName: r.ArtistName, + ArtistMBID: artist.ArtistMBID, + Popularity: r.TotalListenCount, + Duration: r.Length, + CAAReleaseMBID: r.CAAReleaseMBID, + ReleaseName: r.ReleaseName, + }) + } + + return results +} + +// --------------------------------------------------------------------------- +// 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) + + for _, a := range artists { + batch = append(batch, SearchIndexResult{ + EntityType: "artist", + MBID: a.ArtistMBID, + Title: a.ArtistName, + ArtistName: a.ArtistName, + ArtistMBID: a.ArtistMBID, + // Popularity intentionally 0 — backfilled by Tier 5. + }) + + if len(batch) >= indexBatchSize { + si.upsertBatch(batch) + batch = batch[:0] + } + } + + if len(batch) > 0 { + 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 + if end > len(results) { + end = len(results) + } + + si.upsertBatch(results[i:end]) + } +} + +// upsertBatch writes a batch of SearchIndexResult entries to the index +// inside a single transaction. This is the ONE function that all +// writes go through. All fields are handled — callers don't need to +// know which columns exist for which entity types. +func (si *SearchIndex) upsertBatch(entries []SearchIndexResult) { + if len(entries) == 0 { + return + } + + tx, err := si.db.BeginTx() + if err != nil { + si.logger.Warn("search index: begin tx error", "error", err) + + return + } + + for _, e := range entries { + if e.MBID == "" { + continue // skip entries without MBIDs — can't be looked up + } + + inLib := 0 + if e.InLibrary { + inLib = 1 + } + + isSim := 0 + if e.IsSimilar { + isSim = 1 + } + + discogFetched := 0 + if e.DiscogFetched { + discogFetched = 1 + } + + if _, err := tx.Exec(` + 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: upsert error", + "mbid", e.MBID, + "error", err, + ) + } + } + + if err := tx.Commit(); err != nil { + si.logger.Warn("search index: commit error", "error", err) + } +} + +// --------------------------------------------------------------------------- +// 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' AND discog_fetched = 1", + ) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + result := make(map[string]bool) + + for rows.Next() { + var mbid string + if err := rows.Scan(&mbid); err == nil { + result[mbid] = true + } + } + + return result +} + +// getLibraryArtistMBIDs returns MBIDs for all library artists that have one. +// Used when Tier 3 was skipped but Tier 4 needs the library MBID list. +func (si *SearchIndex) getLibraryArtistMBIDs() []string { + rows, err := si.db.QueryContext( + "SELECT DISTINCT mbid FROM artists WHERE mbid IS NOT NULL AND mbid != ''", + ) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + var mbids []string + + for rows.Next() { + var mbid string + if err := rows.Scan(&mbid); err == nil { + mbids = append(mbids, mbid) + } + } + + return mbids +} + +func (si *SearchIndex) isMetaFresh(key string, maxAge time.Duration) bool { + rows, err := si.db.QueryContext( + "SELECT value FROM explore_index_meta WHERE key = ?", key, + ) + if err != nil { + return false + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + return false + } + + var val string + if err := rows.Scan(&val); err != nil { + return false + } + + t, err := time.Parse(time.RFC3339, val) + if err != nil { + return false + } + + return time.Since(t) < maxAge +} + +// loadCachedSitewideArtists reads artist entries from the existing +// index when Tier 1 is fresh and doesn't need re-fetching. +func (si *SearchIndex) loadCachedSitewideArtists() []lbSitewideArtist { + rows, err := si.db.QueryContext(` + SELECT mbid, title, popularity + FROM explore_index + WHERE entity_type = 'artist' + ORDER BY popularity DESC + `) + if err != nil { + return nil + } + + defer func() { _ = rows.Close() }() + + var artists []lbSitewideArtist + + maxL := 0 + + for rows.Next() { + var a lbSitewideArtist + if err := rows.Scan(&a.ArtistMBID, &a.ArtistName, &a.ListenCount); err == nil { + artists = append(artists, a) + + if a.ListenCount > maxL { + maxL = a.ListenCount + } + } + } + + si.mu.Lock() + si.maxListens = maxL + si.mu.Unlock() + + return artists +} + +// filterUnindexed returns artists whose MBIDs are not in the +// indexed set. +func filterUnindexed(artists []lbSitewideArtist, indexed map[string]bool) []lbSitewideArtist { + var out []lbSitewideArtist + + for _, a := range artists { + if !indexed[a.ArtistMBID] { + out = append(out, a) + } + } + + return out +} + +// markInLibrary sets in_library=1 for all index entries whose +// 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, + 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) { + if len(similar) == 0 { + return + } + + tx, err := si.db.BeginTx() + if err != nil { + return + } + + defer func() { _ = tx.Rollback() }() + + // Clear existing entries for this source to avoid stale data. + _, _ = tx.Exec( + "DELETE FROM similar_artist_map WHERE source_artist_mbid = ?", + sourceMBID, + ) + + for _, s := range similar { + _, _ = tx.Exec(` + INSERT OR IGNORE INTO similar_artist_map + (source_artist_mbid, similar_artist_mbid, similar_artist_name, score) + VALUES (?, ?, ?, ?) + `, sourceMBID, s.ArtistMBID, s.Name, s.Score) + } + + _ = tx.Commit() +} + +// markSimilar sets is_similar=1 for all index entries whose +// artist_mbid matches one of the given artists. +func (si *SearchIndex) markSimilar(artists []lbSitewideArtist) { + for _, a := range artists { + _, _ = si.db.ExecContext( + "UPDATE explore_index SET is_similar = 1 WHERE artist_mbid = ?", + a.ArtistMBID, + ) + } +} + +// 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() { + _, _ = si.db.ExecContext( + "DELETE FROM explore_index_meta WHERE key IN ('discog_built', 'tier2_built', 'tier3_built', 'tier4_built')", + ) +} + +func (si *SearchIndex) setMeta(key, value string) { + if _, err := si.db.ExecContext( + "INSERT OR REPLACE INTO explore_index_meta (key, value) VALUES (?, ?)", + key, value, + ); err != nil { + si.logger.Warn("search index: set meta error", "key", key, "error", err) + } +} + +// MarkReadyIfPopulated sets the index as ready for querying if it +// already contains data from a previous build. Called eagerly at +// service creation so the index is queryable before StartBuild runs. +func (si *SearchIndex) MarkReadyIfPopulated() { + rows, err := si.db.QueryContext("SELECT COUNT(*) FROM explore_index") + if err != nil { + return + } + + defer func() { _ = rows.Close() }() + + if rows.Next() { + var count int + if err := rows.Scan(&count); err == nil && count > 0 { + si.mu.Lock() + si.ready = true + si.mu.Unlock() + + si.logger.Info("search index: using existing index", "entries", count) + } + } +} + +// 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. +// 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() + + if maxL <= 0 || listenCount <= 0 { + return indexMinRGs, indexMinRecs + } + + ratio := math.Pow(float64(listenCount)/float64(maxL), indexPopularityExponent) + + rgs = int(float64(indexMinRGs) + ratio*float64(indexMaxRGs-indexMinRGs)) + recs = int(float64(indexMinRecs) + ratio*float64(indexMaxRecs-indexMinRecs)) + + rgs = max(indexMinRGs, min(indexMaxRGs, rgs)) + recs = max(indexMinRecs, min(indexMaxRecs, recs)) + + return rgs, recs +} + +// newLBRequest creates an HTTP GET request with the LB User-Agent. +func newLBRequest(ctx context.Context, url string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", lbUserAgent) + + return req, nil +} diff --git a/backend/explore/types.go b/backend/explore/types.go new file mode 100644 index 0000000..1e9b7a9 --- /dev/null +++ b/backend/explore/types.go @@ -0,0 +1,208 @@ +package explore + +// Wails-serializable wrapper types for MusicBrainz, ListenBrainz, +// and Cover Art Archive API responses. These are the types that +// appear in the generated TypeScript bindings — all fields are +// exported with plain Go types (no mbtypes.MBID, no +// mbtypes.Duration) so the Wails type generator produces clean TS +// interfaces. + +// MBSearchResult aggregates the three searchable entity types +// returned by the MusicBrainz search API. +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. +type MBArtist struct { + MBID string `json:"mbid"` + Name string `json:"name"` + SortName string `json:"sortName"` + EnglishName string `json:"englishName,omitempty"` + Type string `json:"type"` + 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:"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 +// release group. +type MBReleaseGroup struct { + MBID string `json:"mbid"` + Title string `json:"title"` + PrimaryType string `json:"primaryType"` + SecondaryTypes []string `json:"secondaryTypes,omitempty"` + FirstReleaseDate string `json:"firstReleaseDate"` + ArtistCredit string `json:"artistCredit"` + Score int `json:"-"` // MB search relevance, used for reranking + Popularity int `json:"popularity"` // raw LB listen count (0 if unknown) + 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. +type MBRelease struct { + MBID string `json:"mbid"` + Title string `json:"title"` + Date string `json:"date"` + Country string `json:"country"` + Status string `json:"status"` + Tracks []MBTrack `json:"tracks,omitempty"` +} + +// 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"` + 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. +type MBTrack struct { + Position int `json:"position"` + DiscNumber int `json:"discNumber"` + 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 +// ListenBrainz popularity API. +// +// JSON tags use camelCase for Wails→frontend serialization. +// The API response uses snake_case, so we unmarshal into +// lbTopRecordingWire first, then convert. +type LBTopRecording struct { + RecordingMBID string `json:"recordingMbid"` + 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 +// JSON response for the popularity/top-recordings-for-artist +// endpoint. +type lbTopRecordingWire struct { + RecordingMBID string `json:"recording_mbid"` + 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 { + return LBTopRecording{ + RecordingMBID: w.RecordingMBID, + ArtistName: w.ArtistName, + TrackName: w.RecordingName, + TotalListenCount: w.TotalListenCount, + CAAReleaseMBID: w.CAAReleaseMBID, + ReleaseName: w.ReleaseName, + Length: w.Length, + } +} + +// LBSimilarArtist represents a similar artist from the +// ListenBrainz labs API. +type LBSimilarArtist struct { + ArtistMBID string `json:"artistMbid"` + Name string `json:"name"` + Score float64 `json:"score"` +} + +// LBTopReleaseGroup represents a popular release group from the +// ListenBrainz popularity API. +type LBTopReleaseGroup struct { + ReleaseGroupMBID string `json:"releaseGroupMbid"` + Title string `json:"title"` + ArtistName string `json:"artistName"` + 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 +// JSON response for the popularity/top-release-groups-for-artist +// endpoint. +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"` + CAAReleaseMBID string `json:"caa_release_mbid"` + } `json:"release_group"` + Artist struct { + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + } `json:"artist"` +} + +func (w lbTopReleaseGroupWire) toPublic() LBTopReleaseGroup { + artistName := "" + if len(w.Artist.Artists) > 0 { + artistName = w.Artist.Artists[0].Name + } + + return LBTopReleaseGroup{ + ReleaseGroupMBID: w.ReleaseGroupMBID, + Title: w.ReleaseGroup.Name, + ArtistName: artistName, + 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 ca85bd5..e92038c 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -76,9 +76,16 @@ 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() + // OnAllScansComplete runs after ALL queued scans finish + // (queue drained). + OnAllScansComplete func() } // Library manages scanning and querying the music collection. @@ -691,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() } @@ -973,7 +983,7 @@ func (l *Library) saveAudioFile( // Process metadata and create related records. recordingID, err := l.processMetadata( - q, cache, metrics, result, thumbChan, + q, tx, cache, metrics, result, thumbChan, ) if err != nil { return fmt.Errorf("could not process metadata: %w", err) @@ -1067,7 +1077,7 @@ func (l *Library) updateAudioFileMetadata( // Process metadata and create related records. recordingID, err := l.processMetadata( - q, cache, metrics, result, thumbChan, + q, tx, cache, metrics, result, thumbChan, ) if err != nil { return fmt.Errorf("could not process metadata: %w", err) @@ -1148,6 +1158,7 @@ func (l *Library) updateAudioFileMetadata( // asynchronously. func (l *Library) processMetadata( q *sqlcgen.Queries, + tx *sql.Tx, cache *entityCache, metrics *ScanMetrics, result importResult, @@ -1234,9 +1245,60 @@ func (l *Library) processMetadata( } } + // 7. Update MusicBrainz IDs (if present in tags). + if releaseGroupID.Valid { + l.updateMBIDs(tx, cache, tags, artistName, releaseGroupID.Int64, recording.ID) + } else { + l.updateMBIDs(tx, cache, tags, artistName, 0, recording.ID) + } + return recording.ID, nil } +// updateMBIDs writes MusicBrainz IDs from audio file tags to the +// corresponding database entities. Uses raw SQL since the sqlc +// queries predate the mbid columns. Skips silently if tags have +// no MBIDs. +func (l *Library) updateMBIDs( + tx *sql.Tx, + cache *entityCache, + tags *metadata.TrackMetadata, + artistName string, + releaseGroupID int64, + recordingID int64, +) { + // Artist MBID — prefer album artist, fall back to track artist. + artistMBID := tags.AlbumArtistMBID + if artistMBID == "" { + artistMBID = tags.ArtistMBID + } + + if artistMBID != "" { + if artist, ok := cache.artists[artistName]; ok { + _, _ = tx.ExecContext(l.ctx, + "UPDATE artists SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')", + artistMBID, artist.ID, + ) + } + } + + // Release group MBID. + if tags.ReleaseGroupMBID != "" && releaseGroupID > 0 { + _, _ = tx.ExecContext(l.ctx, + "UPDATE release_groups SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')", + tags.ReleaseGroupMBID, releaseGroupID, + ) + } + + // Recording MBID. + if tags.RecordingMBID != "" && recordingID > 0 { + _, _ = tx.ExecContext(l.ctx, + "UPDATE recordings SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')", + tags.RecordingMBID, recordingID, + ) + } +} + // processCoverArt saves cover art to disk and upserts the DB record, // using the cache to skip work for previously seen images. When // thumbChan is non-nil, thumbnail generation is dispatched to the diff --git a/backend/library/query.go b/backend/library/query.go index f0c6e5b..e35a250 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -4,12 +4,15 @@ import ( "database/sql" "errors" "fmt" + "os" + "path/filepath" "strconv" "strings" "time" "yellowjacket/backend/coverart" "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/system" ) // Sentinel errors for library queries. @@ -20,24 +23,31 @@ var ( // Track represents a playable audio file in the library. type Track struct { - TrackName string - ArtistName string - TrackLength string - FilePath string - TrackNumber int64 - DiscNumber int64 - Album string - Genre []string - Year int64 - Composer string - FileType string - SampleRate int64 - BitDepth int64 - Channels int64 - Bitrate int64 - FileSize int64 - PlayCount int64 - LastPlayed string + TrackName string + ArtistName string + TrackLength string + FilePath string + TrackNumber int64 + DiscNumber int64 + Album string + Genre []string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 + PlayCount int64 + LastPlayed string + RecordingMBID string + ArtistMBID string + ReleaseGroupMBID string + CoverArtPath string + CoverArtSmall string + CoverArtMedium string + CoverArtLarge string } // genreDelimiter is the separator used by GROUP_CONCAT in the @@ -68,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), @@ -91,15 +103,73 @@ 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 +// from the recording, release group, and artist tables. +type TrackMBIDs struct { + RecordingMBID string `json:"recordingMbid"` + ReleaseGroupMBID string `json:"releaseGroupMbid"` + ArtistMBID string `json:"artistMbid"` +} + +// GetTrackMBIDs returns the MusicBrainz IDs for the track at the +// given file path. Returns empty strings for entities without MBIDs. +func (l *Library) GetTrackMBIDs(filePath string) TrackMBIDs { + rows, err := l.db.QueryContext(` + SELECT + COALESCE(r.mbid, '') AS recording_mbid, + COALESCE(rg.mbid, '') AS release_group_mbid, + COALESCE(a.mbid, '') AS artist_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 + JOIN artist_credit_artist aca ON aca.credit_id = ac.id + 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 + WHERE af.file_path = ? + LIMIT 1 + `, filePath) + if err != nil { + return TrackMBIDs{} + } + + defer func() { _ = rows.Close() }() + + var result TrackMBIDs + + if rows.Next() { + _ = rows.Scan(&result.RecordingMBID, &result.ReleaseGroupMBID, &result.ArtistMBID) + } + + return result } // Artist represents an artist in the library. type Artist struct { - ID int64 - Name string + ID int64 + Name string + MBID string + ImageSmall string + ImageMedium string + ImageLarge string } // Album represents an album for the cover grid display. @@ -107,6 +177,7 @@ type Album struct { ID int64 Name string ArtistName string + MBID string CoverArtPath string CoverArtSmall string CoverArtMedium string @@ -158,6 +229,10 @@ func (l *Library) GetAllTracks() ([]Track, error) { row.FileSize, row.PlayCount, row.LastPlayed, + row.CoverArtPath, + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, )) } @@ -211,6 +286,8 @@ func (l *Library) SearchTracks( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + "", "", "", )) } @@ -251,6 +328,10 @@ func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) { row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, )) } @@ -281,6 +362,10 @@ func (l *Library) GetAllAlbums() ([]Album, error) { album.Year = row.Year.Int64 } + if row.Mbid.Valid { + album.MBID = row.Mbid.String + } + // Convert filesystem path to URL path for the asset handler. if row.CoverArtPath != "" { urls := coverart.ResolveURLs(row.CoverArtPath) @@ -316,15 +401,81 @@ func (l *Library) GetAllArtists() ([]Artist, error) { artists := make([]Artist, 0, len(rows)) for _, row := range rows { - artists = append(artists, Artist{ + a := Artist{ ID: row.ID, Name: row.Name, - }) + } + + if row.Mbid.Valid { + a.MBID = row.Mbid.String + } + + artists = append(artists, a) } + // Resolve artist image URLs from the disk cache. + l.resolveArtistImages(artists) + return artists, nil } +// resolveArtistImages populates ImageSmall/Medium/Large for artists +// that have cached images on disk. Does a bulk MBID lookup from the +// artists table, then checks the artist-images directory for each. +func (l *Library) resolveArtistImages(artists []Artist) { + if len(artists) == 0 { + return + } + + dataDir, err := system.GetUserDataDirPath() + if err != nil { + return + } + + baseDir := filepath.Join(dataDir, "artist-images") + + // Bulk load name→mbid from the artists table. + rows, err := l.db.QueryContext( + "SELECT name, mbid FROM artists WHERE mbid IS NOT NULL AND mbid != ''", + ) + if err != nil { + return + } + + defer func() { _ = rows.Close() }() + + mbidMap := make(map[string]string) + + for rows.Next() { + var name, mbid string + if err := rows.Scan(&name, &mbid); err == nil { + mbidMap[name] = mbid + } + } + + for i := range artists { + mbid, ok := mbidMap[artists[i].Name] + if !ok || len(mbid) < 2 { + continue + } + + dir := filepath.Join(baseDir, mbid[:2], mbid) + prefix := "/artist-images/" + mbid[:2] + "/" + mbid + "/" + + if _, err := os.Stat(filepath.Join(dir, "primary_sm.jpg")); err == nil { + artists[i].ImageSmall = prefix + "primary_sm.jpg" + } + + if _, err := os.Stat(filepath.Join(dir, "primary_md.jpg")); err == nil { + artists[i].ImageMedium = prefix + "primary_md.jpg" + } + + if _, err := os.Stat(filepath.Join(dir, "primary_lg.jpg")); err == nil { + artists[i].ImageLarge = prefix + "primary_lg.jpg" + } + } +} + // GetAlbumsByArtist returns all albums where the given artist is the album artist. func (l *Library) GetAlbumsByArtist( artistID int64, @@ -426,6 +577,8 @@ func (l *Library) GetTracksByGenre( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + "", "", "", )) } @@ -509,6 +662,10 @@ func (l *Library) GetAllTracksByLibrary( row.FileSize, row.PlayCount, row.LastPlayed, + row.CoverArtPath, + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, )) } @@ -553,6 +710,10 @@ func (l *Library) GetAllAlbumsByLibrary( album.Year = row.Year.Int64 } + if row.Mbid.Valid { + album.MBID = row.Mbid.String + } + if row.CoverArtPath != "" { urls := coverart.ResolveURLs(row.CoverArtPath) album.CoverArtPath = urls.Original @@ -596,12 +757,20 @@ func (l *Library) GetAllArtistsByLibrary( artists := make([]Artist, 0, len(rows)) for _, row := range rows { - artists = append(artists, Artist{ + a := Artist{ ID: row.ID, Name: row.Name, - }) + } + + if row.Mbid.Valid { + a.MBID = row.Mbid.String + } + + artists = append(artists, a) } + l.resolveArtistImages(artists) + return artists, nil } @@ -742,6 +911,8 @@ func (l *Library) GetTracksByGenreByLibrary( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + "", "", "", )) } @@ -794,6 +965,10 @@ func (l *Library) GetAlbumTracksByLibrary( row.Bitrate, row.FileSize, 0, sql.NullTime{}, + "", + row.ArtistMbid, + row.ReleaseGroupMbid, + row.RecordingMbid, )) } @@ -842,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 a31ff92..b8e75bb 100644 --- a/backend/library/rescan.go +++ b/backend/library/rescan.go @@ -80,6 +80,11 @@ func (l *Library) FullRescan() (*ScanMetrics, error) { } } + // Drain the queue in a goroutine so any queued libraries + // scan sequentially. scanInternal was called directly (not + // via startScan), so drainQueue hasn't been invoked yet. + go l.drainQueue() + if metrics != nil { metrics.ClearQueue = clearQueueDur metrics.ClearDatabase = clearDBDur @@ -119,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/library/scan_queue.go b/backend/library/scan_queue.go index fc46018..3c3e3b4 100644 --- a/backend/library/scan_queue.go +++ b/backend/library/scan_queue.go @@ -254,7 +254,12 @@ func (l *Library) drainQueue() { l.currentScanLibraryID = 0 l.currentScanLibraryName = "" l.scanActive = false + hooks := l.scanHooks l.mu.Unlock() runtime.EventsEmit(l.ctx, events.LibraryScanQueueDrained) + + if hooks.OnAllScansComplete != nil { + hooks.OnAllScansComplete() + } } diff --git a/backend/library/scan_test.go b/backend/library/scan_test.go index 84f51a8..dd17010 100644 --- a/backend/library/scan_test.go +++ b/backend/library/scan_test.go @@ -209,6 +209,10 @@ func TestMapTrackRow(t *testing.T) { 35000000, // fileSize 0, // playCount sql.NullTime{}, // lastPlayed + "", // coverArtPath + "", // artistMBID + "", // releaseGroupMBID + "", // recordingMBID ) // Verify all 16 fields. @@ -291,6 +295,8 @@ func TestMapTrackRow(t *testing.T) { "", "", 0, "", "", 0, 0, 0, 0, 0, 0, // playCount sql.NullTime{}, // lastPlayed + "", // coverArtPath + "", "", "", // artistMBID, releaseGroupMBID, recordingMBID ) if trackNull.TrackNumber != 0 { diff --git a/backend/metadata/tags.go b/backend/metadata/tags.go index 0d01d90..034e636 100644 --- a/backend/metadata/tags.go +++ b/backend/metadata/tags.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/dhowden/tag" ) @@ -30,6 +31,13 @@ type TrackMetadata struct { Lyrics string Comment string + // MusicBrainz IDs (from tags, may be empty) + ArtistMBID string + AlbumArtistMBID string + ReleaseGroupMBID string + ReleaseMBID string + RecordingMBID string + // Cover art (if present) Picture *PictureData @@ -90,6 +98,9 @@ func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) { FileFormat: string(m.FileType()), } + // Extract MusicBrainz IDs from raw tags. + extractMBIDs(m.Raw(), meta) + // Extract picture if present if pic := m.Picture(); pic != nil { meta.Picture = &PictureData{ @@ -101,3 +112,72 @@ func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) { return meta, nil } + +// mbidTagKeys maps TrackMetadata field names to the possible raw +// tag keys across formats (ID3v2 TXXX, Vorbis, MP4). All keys +// are lowercased for case-insensitive matching. +var mbidTagKeys = map[string][]string{ + "ArtistMBID": {"musicbrainz_artistid", "musicbrainz artist id"}, + "AlbumArtistMBID": {"musicbrainz_albumartistid", "musicbrainz album artist id"}, + "ReleaseGroupMBID": {"musicbrainz_releasegroupid", "musicbrainz release group id"}, + "ReleaseMBID": {"musicbrainz_albumid", "musicbrainz album id"}, + "RecordingMBID": {"musicbrainz_trackid", "musicbrainz recording id"}, +} + +// extractMBIDs populates the MBID fields of meta from the raw tag +// map. Handles both Vorbis comments (plain string values with +// lowercase keys) and ID3v2 TXXX frames (*tag.Comm values with +// TXXX_N keys and the tag name in the Description field). +func extractMBIDs(raw map[string]interface{}, meta *TrackMetadata) { + if len(raw) == 0 { + return + } + + // Build a lowercased description → value map that works for + // both formats: + // Vorbis: key="musicbrainz_artistid", value="uuid" (string) + // ID3v2: key="TXXX_13", value=*tag.Comm{Description:"MusicBrainz Artist Id", Text:"uuid"} + normalized := make(map[string]string, len(raw)) + + for k, v := range raw { + switch val := v.(type) { + case string: + // Vorbis comments — key is the tag name. + normalized[strings.ToLower(k)] = val + + case *tag.Comm: + // ID3v2 TXXX frames — Description is the tag name. + if val != nil && val.Description != "" { + text := strings.TrimRight(val.Text, "\x00 \t\n\r") + normalized[strings.ToLower(val.Description)] = text + } + + case *tag.UFID: + // ID3v2 UFID frame — MusicBrainz recording ID. + if val != nil && val.Provider == "http://musicbrainz.org" { + meta.RecordingMBID = strings.TrimRight(string(val.Identifier), "\x00 \t\n\r") + } + } + } + + for field, keys := range mbidTagKeys { + for _, key := range keys { + if val, ok := normalized[key]; ok && val != "" { + switch field { + case "ArtistMBID": + meta.ArtistMBID = val + case "AlbumArtistMBID": + meta.AlbumArtistMBID = val + case "ReleaseGroupMBID": + meta.ReleaseGroupMBID = val + case "ReleaseMBID": + meta.ReleaseMBID = val + case "RecordingMBID": + meta.RecordingMBID = val + } + + break + } + } + } +} 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.css b/frontend/index.css index e6edb7e..39e0f2d 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -40,6 +40,68 @@ p { flex: 0 1 320px; } +.mode-toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + flex-shrink: 0; +} + +.mode-toggle-track { + position: relative; + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.15)); + transition: background 0.2s ease; +} + +.mode-toggle:hover .mode-toggle-track { + background: rgba(255, 255, 255, 0.22); +} + +.mode-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--yj-text-primary, #fff); + transition: left 0.2s ease, background 0.2s ease; +} + +.mode-toggle.active .mode-toggle-track { + background: var(--yj-accent, #ffd43b); +} + +.mode-toggle.active .mode-toggle-thumb { + left: 18px; + background: #000; +} + +.mode-icon { + font-size: 14px; + transition: color 0.2s ease, opacity 0.2s ease; +} + +.mode-icon-globe { + color: var(--yj-text-primary, #fff); +} + +.mode-icon-local { + color: var(--yj-text-secondary, #888); +} + +.mode-toggle.active .mode-icon-globe { + color: var(--yj-text-secondary, #888); +} + +.mode-toggle.active .mode-icon-local { + color: var(--yj-accent, #ffd43b); +} + ul { list-style-type: none; } @@ -142,14 +204,17 @@ body div.sidebar { .main-panel { flex: 1; min-width: 0; - padding: 0.25em; + display: flex; + flex-direction: column; background-color: var(--yj-bg-surface, #212529); overflow: hidden; contain: layout style paint; } .main-panel > * { - height: 100%; + flex: 1; + min-height: 0; + box-sizing: border-box; contain: layout style paint; } @@ -158,7 +223,13 @@ body div.sidebar { display:none discards scroll state in WebKitGTK. */ .main-panel > .view-hidden { visibility: hidden !important; + flex: 0 0 0px !important; + min-height: 0 !important; + max-height: 0 !important; height: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: none !important; overflow: hidden !important; pointer-events: none !important; contain: strict !important; diff --git a/frontend/index.html b/frontend/index.html index 81c7bfb..c494472 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,6 +15,13 @@

YellowJacket

Music how it was meant to bee.

+
+ +
+
+
+ +
diff --git a/frontend/index.ts b/frontend/index.ts index a42ab71..eecf4cb 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -16,6 +16,9 @@ import '@components/smart-playlist-editor/smart-playlist-editor.ts'; import '@components/search-bar/search-bar.ts'; import '@components/library-filter/library-filter.ts'; import '@components/track-details/track-details.ts'; +import '@components/explore-view/explore-view.ts'; +import '@components/explore-artist-details/explore-artist-details.js'; +import '@components/explore-album-details/explore-album-details.js'; import '@awesome.me/webawesome/dist/styles/themes/default.css'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; @@ -29,6 +32,7 @@ import '@store/theme-store'; // Importing the keyboard shortcut service triggers initialization: // registers the document keydown listener for global shortcuts. import './src/services/keyboard-shortcut-service'; +import { exploreSettings } from '@store/explore-settings'; import { hasTrackPayload, getDragPayload, @@ -54,6 +58,7 @@ const VIEW_TAGS: Record = { artists: 'artists-view', genres: 'genres-view', playlists: 'playlist-view', + explore: 'explore-view', settings: 'config-page', }; @@ -61,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'); @@ -83,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(); @@ -106,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'); @@ -120,6 +140,8 @@ document.addEventListener('navigate', (e: Event) => { currentDetailEl = null; } + currentNavDetail = { ...detail }; + switch (view) { case 'artist-details': { const { artistId, artistName } = detail; @@ -163,6 +185,32 @@ document.addEventListener('navigate', (e: Event) => { currentDetailEl = genreEl; break; } + case 'explore-artist-details': { + const { artistMBID, artistName, localArtistId } = detail; + const el = document.createElement('explore-artist-details'); + + 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, artistName, highlightTrackMBID, localAlbumId } = detail; + const el = document.createElement('explore-album-details'); + + 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; + } default: { const fallback = document.createElement('div'); @@ -175,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; @@ -244,3 +304,24 @@ if (queueButton && queuePanel) { // or timing assumptions needed. void Player.EmitCurrentState(); void Queue.EmitCurrentState(); + +// --------------------------------------------------------------------------- +// Library Only toggle +// --------------------------------------------------------------------------- +const libraryOnlyToggle = document.getElementById('library-only-toggle'); + +if (libraryOnlyToggle) { + // Sync initial state. + if (exploreSettings.libraryOnly) { + libraryOnlyToggle.classList.add('active'); + } + + libraryOnlyToggle.addEventListener('click', () => { + exploreSettings.toggle(); + libraryOnlyToggle.classList.toggle('active', exploreSettings.libraryOnly); + }); + + exploreSettings.subscribe(() => { + libraryOnlyToggle.classList.toggle('active', exploreSettings.libraryOnly); + }); +} diff --git a/frontend/src/components/artist-details/artist-details.ts b/frontend/src/components/artist-details/artist-details.ts index 4aca04e..b75c30c 100644 --- a/frontend/src/components/artist-details/artist-details.ts +++ b/frontend/src/components/artist-details/artist-details.ts @@ -6,6 +6,7 @@ import { } from 'lit/decorators.js'; import { library } from '@go/models'; import { LibraryController } from '@store/controllers/library-controller'; +import { GetArtistImageURL, GetArtistMBID } from '@go/explore/Service'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/cover-grid/cover-grid.js'; import { designTokens } from '../../styles/tokens.css'; @@ -18,12 +19,18 @@ export class ArtistDetails extends LitElement { @property({ type: String, attribute: 'artist-name' }) artistName = ''; + @property({ type: String, attribute: 'artist-mbid' }) + artistMBID = ''; + @state() private albums: library.Album[] = []; @state() private loading = true; + @state() + private artistImageURL = ''; + private libraryCtrl = new LibraryController(this); /** Tracks the store's cached array reference to detect refreshes. */ @@ -35,6 +42,7 @@ export class ArtistDetails extends LitElement { flex-direction: column; overflow: hidden; height: 100%; + box-sizing: border-box; } /* ==================================== @@ -99,6 +107,12 @@ export class ArtistDetails extends LitElement { flex-shrink: 0; } + .artist-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + } + .artist-avatar .initial { color: var( --yj-text-secondary, @@ -156,6 +170,7 @@ export class ArtistDetails extends LitElement { override connectedCallback() { super.connectedCallback(); this.loadAlbums(); + this.loadArtistImage(); } override updated() { @@ -174,6 +189,31 @@ export class ArtistDetails extends LitElement { * Data loading * ================================================================ */ + private async loadArtistImage() { + // Resolve MBID from tags if not provided via attribute. + let mbid = this.artistMBID; + + if (!mbid && this.artistName) { + try { + mbid = await GetArtistMBID(this.artistName); + } catch { + return; + } + } + + if (!mbid) return; + + try { + const url = await GetArtistImageURL(mbid); + + if (url) { + this.artistImageURL = url; + } + } catch { + // No image — avatar stays as initial letter. + } + } + private async loadAlbums() { if (!this.artistId) return; @@ -293,11 +333,14 @@ export class ArtistDetails extends LitElement { >
- - ${this.getInitial( - this.artistName, - )} - + ${this.artistImageURL + ? html`${this.artistName}` + : html` + ${this.getInitial(this.artistName)} + `}

`; + } + + return html` + ${this.getArtistInitial(artist.Name)} + `; + } + private getArtistInitial( name: string, ): string { @@ -1034,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, }, }, ), @@ -1047,11 +1105,7 @@ export class ArtistsView }} >
- - ${this.getArtistInitial( - artist.Name, - )} - + ${this.renderArtistAvatar(artist)}
void; private cancelScanResumed?: () => void; private cancelScanCancelled?: () => void; + private cancelIndexStatus?: () => void; + private indexPollTimer?: ReturnType; private cancelScanQueued?: () => void; private cancelScanQueueDrained?: () => void; private cancelLibraryAdded?: () => void; @@ -386,6 +390,8 @@ export class ConfigPage extends LitElement { :host { display: block; padding: 1.5em; + height: 100%; + box-sizing: border-box; color: var(--yj-text-primary, #fff); font-family: system-ui, -apple-system, sans-serif; overflow-y: auto; @@ -1095,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); + } + `; // =================================================================== @@ -1154,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 { @@ -1173,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 { @@ -1846,6 +1927,7 @@ export class ConfigPage extends LitElement { return html`

Settings

+ ${this.renderSearchSection()} ${this.renderNowPlayingSection()} ${this.renderThemeSection()} ${this.renderFavoritesSection()} @@ -1855,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-styles.ts b/frontend/src/components/cover-grid/cover-grid-styles.ts index 7b7ded7..8f2c329 100644 --- a/frontend/src/components/cover-grid/cover-grid-styles.ts +++ b/frontend/src/components/cover-grid/cover-grid-styles.ts @@ -8,6 +8,7 @@ const gridStyles = css` display: flex; flex-direction: column; overflow: hidden; + height: 100%; position: relative; contain: layout style; } 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 new file mode 100644 index 0000000..5342817 --- /dev/null +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -0,0 +1,1730 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +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'; + +/* ── Utility functions (duplicated per Knowledge Pattern #9 — no cross-component imports) ── */ + +function CoverArtGroupURL(releaseGroupMBID: string): string { + return `${CAA_GROUP_BASE}/${releaseGroupMBID}/front-250`; +} + +function nameToHue(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return Math.abs(hash) % 360; +} + +function extractYear(dateStr: string): string { + if (!dateStr) return ''; + return dateStr.substring(0, 4); +} + +/** + * Convert a duration in milliseconds to a human-readable "m:ss" string. + * Returns "0:00" for zero/negative/NaN values. + */ +function formatDuration(ms: number): string { + if (!ms || ms <= 0) return '0:00'; + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +/* ── Types ── */ + +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
${track.filePath diff --git a/frontend/src/components/playlist-details/playlist-details.ts b/frontend/src/components/playlist-details/playlist-details.ts index c58174d..0859213 100644 --- a/frontend/src/components/playlist-details/playlist-details.ts +++ b/frontend/src/components/playlist-details/playlist-details.ts @@ -52,6 +52,12 @@ import type { PhantomResolver } from '@components/phantom-resolver/phantom-resol import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js'; import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js'; import { formatMilliseconds } from '@utils/time'; +import { + artistLink, + albumLink, + trackLink, + exploreLinkStyles, +} from '@utils/explore-link'; import { designTokens } from '../../styles/tokens.css'; @customElement('playlist-details') @@ -441,12 +447,18 @@ export class PlaylistDetails if (!track) return; - const coverArt = - this.resolvePlaylistCoverArt(track.Album); + const coverArt = track.CoverArtPath + ? { + coverArtPath: track.CoverArtPath, + coverArtSmall: track.CoverArtSmall, + coverArtMedium: track.CoverArtMedium, + coverArtLarge: track.CoverArtLarge, + } + : undefined; this.trackDetailsDialog?.show( track, - coverArt ?? undefined, + coverArt, ); } @@ -471,19 +483,19 @@ export class PlaylistDetails if (tracks.length === 0) return; - const albumNames = new Set( - tracks.map((t) => t.Album), - ); + const first = tracks[0]!; + const albumNames = new Set(tracks.map((t) => t.Album)); let coverArt: CoverArtUrls | null = null; let coverArtMixed = false; - if (albumNames.size === 1) { - const albumName = [...albumNames][0]!; - coverArt = - this.resolvePlaylistCoverArt( - albumName, - ); - } else { + if (albumNames.size === 1 && first.CoverArtPath) { + coverArt = { + coverArtPath: first.CoverArtPath, + coverArtSmall: first.CoverArtSmall, + coverArtMedium: first.CoverArtMedium, + coverArtLarge: first.CoverArtLarge, + }; + } else if (albumNames.size > 1) { coverArtMixed = true; } @@ -494,31 +506,6 @@ export class PlaylistDetails ); } - private resolvePlaylistCoverArt( - albumName: string, - ): CoverArtUrls | null { - if (!albumName) return null; - - const albums = libraryStore.getCachedAlbums(); - - if (!albums) return null; - - const album = albums.find( - (a) => a.Name === albumName, - ); - - if (!album || !album.CoverArtPath) { - return null; - } - - return { - coverArtPath: album.CoverArtPath, - coverArtSmall: album.CoverArtSmall, - coverArtMedium: album.CoverArtMedium, - coverArtLarge: album.CoverArtLarge, - }; - } - /** * Check whether all currently selected tracks are phantoms. */ @@ -799,6 +786,7 @@ export class PlaylistDetails static override styles = [ designTokens, contextMenuStyles, + exploreLinkStyles, css` :host { display: flex; @@ -977,7 +965,7 @@ export class PlaylistDetails .track-header, .track-item { display: grid; - grid-template-columns: 40px 1fr 1fr 1fr 80px; + grid-template-columns: 40px 36px 1fr 1fr 1fr 80px; align-items: center; gap: 0; } @@ -993,6 +981,22 @@ export class PlaylistDetails user-select: none; } + .track-art { + width: 32px; + height: 32px; + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; + background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); + } + + .track-art img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + .header-cell, .cell { overflow: hidden; @@ -1017,7 +1021,7 @@ export class PlaylistDetails /* Phantom rows span full grid */ .track-item.phantom { display: grid; - grid-template-columns: 40px 1fr 1fr 1fr 80px; + grid-template-columns: 40px 36px 1fr 1fr 1fr 80px; } .track-item { @@ -1252,6 +1256,7 @@ export class PlaylistDetails

#
+
Title
Artist
Album
@@ -1376,9 +1381,14 @@ export class PlaylistDetails
` : html`${trackIndex + 1} - ${track.Title || track.FilePath} - ${track.Artist} - ${track.Album} +
+ ${track.CoverArtSmall || track.CoverArtMedium + ? html`` + : nothing} +
+ ${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID) || track.FilePath} + ${artistLink(track.Artist, track.ArtistMBID)} + ${albumLink(track.Album, track.ReleaseGroupMBID)} ${formatMilliseconds(track.Duration)}`} `; diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index 95d9526..ac101a7 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -176,6 +176,7 @@ export class PlaylistView extends LitElement { display: flex; flex-direction: column; overflow: hidden; + height: 100%; position: relative; contain: layout style; } diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index 211bea5..cba9321 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -44,7 +44,11 @@ import type { library } from '@go/models'; import '@components/track-details/track-details.js'; import type { TrackDetails } from '@components/track-details/track-details.js'; import type { CoverArtUrls } from '@components/track-details/track-details.js'; - +import { + artistLink, + trackLink, + exploreLinkStyles, +} from '@utils/explore-link'; const MIN_WIDTH = 200; const MAX_WIDTH = 500; const DEFAULT_WIDTH = 320; @@ -207,7 +211,7 @@ export class QueuePanel return this.playlistSubmenuPopup; } - static override styles = [designTokens, contextMenuStyles, css` + static override styles = [designTokens, contextMenuStyles, exploreLinkStyles, css` :host { flex-shrink: 0; width: 0; @@ -353,6 +357,22 @@ export class QueuePanel color: var(--yj-accent, #ffd43b); } + .track-art { + width: 32px; + height: 32px; + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; + background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); + } + + .track-art img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + .track-details { flex: 1; min-width: 0; @@ -865,12 +885,18 @@ export class QueuePanel if (!track) return; - const coverArt = - this.resolveQueueCoverArt(track.Album); + const coverArt = track.CoverArtPath + ? { + coverArtPath: track.CoverArtPath, + coverArtSmall: track.CoverArtSmall, + coverArtMedium: track.CoverArtMedium, + coverArtLarge: track.CoverArtLarge, + } + : undefined; this.trackDetailsDialog?.show( track, - coverArt ?? undefined, + coverArt, ); } @@ -899,17 +925,19 @@ export class QueuePanel if (tracks.length === 0) return; - const albumNames = new Set( - tracks.map((t) => t.Album), - ); + const first = tracks[0]!; + const albumNames = new Set(tracks.map((t) => t.Album)); let coverArt: CoverArtUrls | null = null; let coverArtMixed = false; - if (albumNames.size === 1) { - const albumName = [...albumNames][0]!; - coverArt = - this.resolveQueueCoverArt(albumName); - } else { + if (albumNames.size === 1 && first.CoverArtPath) { + coverArt = { + coverArtPath: first.CoverArtPath, + coverArtSmall: first.CoverArtSmall, + coverArtMedium: first.CoverArtMedium, + coverArtLarge: first.CoverArtLarge, + }; + } else if (albumNames.size > 1) { coverArtMixed = true; } @@ -920,32 +948,6 @@ export class QueuePanel ); } - private resolveQueueCoverArt( - albumName: string, - ): CoverArtUrls | null { - if (!albumName) return null; - - const albums = - libraryStore.getCachedAlbums(); - - if (!albums) return null; - - const album = albums.find( - (a) => a.Name === albumName, - ); - - if (!album || !album.CoverArtPath) { - return null; - } - - return { - coverArtPath: album.CoverArtPath, - coverArtSmall: album.CoverArtSmall, - coverArtMedium: album.CoverArtMedium, - coverArtLarge: album.CoverArtLarge, - }; - } - private onContextPlaylistActionComplete = () => { this.selection.clear(); this.ctxMenu.close(); @@ -1396,6 +1398,8 @@ export class QueuePanel dropIdx === trackCount && index === trackCount - 1; + const artUrl = track.coverArtPath || ''; + // No inline closures — all events delegated via data-index // on the virtualizer element (see firstUpdated). return html` @@ -1413,12 +1417,13 @@ export class QueuePanel ${index + 1} + ${artUrl ? html`
` : nothing}
- ${this.getDisplayTitle(track)} + ${trackLink(this.getDisplayTitle(track), track.album, track.releaseGroupMbid, track.recordingMbid)} - ${track.artist || 'Unknown Artist'} + ${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}