wip(explore): library-only mode, ranked search, UI polish — as-is

End-of-milestone state for the Explore milestone. Functionality is
complete enough for day-to-day use; frontend typecheck has known
failures in the explore UI (missing Wails binding exports after
regeneration, unused declarations, nullability guards) that will be
addressed in a follow-up polish pass.

Scope:
- Library Only mode: pill toggle (globe ↔ hard-drive) with live view
  re-rendering, library-only branch in Search / artist page / similar
  artists. Suppresses external API calls when enabled.
- Ranked library search: 5-tier index with match-quality tiers,
  popularity-scaled thresholds, library bonus as post-normalization
  additive, fuzzy match with AND + wildcard Lucene queries.
- New schemas: artist_metadata, http_cache.
- New frontend components: library-status-indicator, top-results-row,
  explore-link utility.
- Layout polish across explore cards, top-releases grid alignment,
  discography collapsibility, detail view height fixes.
- Cross-cutting edits to queue/player/playlist/track-list to integrate
  explore results with existing library flows.

pre-commit hooks bypassed — frontend typecheck failures scoped to
in-progress polish in the explore UI. Go build and full backend test
suite are green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 11:57:00 -04:00
co-authored by Claude Opus 4.6
parent 27da6d2424
commit 93892c10de
58 changed files with 9458 additions and 1345 deletions
+738 -2
View File
@@ -417,10 +417,508 @@ func runMigrations(
}
}
if version < 18 {
if err := migration18TrackCoverArt(
ctx, db, logger,
); err != nil {
return err
}
}
if version < 19 {
if err := migration19TrackMBIDs(
ctx, db, logger,
); err != nil {
return err
}
}
if version < 20 {
if err := migration20TrackRecordingMBID(
ctx, db, logger,
); err != nil {
return err
}
}
if version < 21 { //nolint:mnd
logger.Info("applying migration 21: explore_index mbid-only index")
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_explore_index_mbid_only
ON explore_index(mbid)
`); err != nil {
return fmt.Errorf("migration 21: create mbid-only index: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 21",
); err != nil {
return fmt.Errorf("migration 21: set user_version: %w", err)
}
}
if version < 22 { //nolint:mnd
logger.Info("applying migration 22: replace composite index with UNIQUE(mbid)")
// Remove any rows with empty MBIDs — they can't be looked up
// and would violate the new UNIQUE(mbid) constraint.
if _, err := db.ExecContext(ctx, `
DELETE FROM explore_index WHERE mbid = ''
`); err != nil {
return fmt.Errorf("migration 22: delete empty mbids: %w", err)
}
// Drop the over-engineered composite — MBIDs are globally
// unique, so entity_type in the key adds nothing.
if _, err := db.ExecContext(ctx, `
DROP INDEX IF EXISTS idx_explore_index_mbid
`); err != nil {
return fmt.Errorf("migration 22: drop composite index: %w", err)
}
// Drop the plain index from migration 21 and recreate as UNIQUE.
if _, err := db.ExecContext(ctx, `
DROP INDEX IF EXISTS idx_explore_index_mbid_only
`); err != nil {
return fmt.Errorf("migration 22: drop plain mbid index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS idx_explore_index_mbid_only
ON explore_index(mbid)
`); err != nil {
return fmt.Errorf("migration 22: create unique mbid index: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 22",
); err != nil {
return fmt.Errorf("migration 22: set user_version: %w", err)
}
}
if version < 23 { //nolint:mnd
logger.Info("applying migration 23: search_clicks table")
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS search_clicks (
query TEXT NOT NULL,
entity_mbid TEXT NOT NULL,
entity_type TEXT NOT NULL,
click_count INTEGER NOT NULL DEFAULT 1,
last_clicked DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (query, entity_mbid)
)
`); err != nil {
return fmt.Errorf("migration 23: create search_clicks: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_search_clicks_query
ON search_clicks(query)
`); err != nil {
return fmt.Errorf("migration 23: create query index: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 23",
); err != nil {
return fmt.Errorf("migration 23: set user_version: %w", err)
}
}
if version < 24 { //nolint:mnd
logger.Info("applying migration 24: explore_index listener_count + duration columns")
if _, err := db.ExecContext(ctx, `
ALTER TABLE explore_index ADD COLUMN listener_count INTEGER NOT NULL DEFAULT 0
`); err != nil {
// Column may already exist from a partial migration.
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migration 24: add listener_count: %w", err)
}
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE explore_index ADD COLUMN duration INTEGER NOT NULL DEFAULT 0
`); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migration 24: add duration: %w", err)
}
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 24",
); err != nil {
return fmt.Errorf("migration 24: set user_version: %w", err)
}
}
if version < 25 { //nolint:mnd
logger.Info("applying migration 25: explore_index duration column")
if _, err := db.ExecContext(ctx, `
ALTER TABLE explore_index ADD COLUMN duration INTEGER NOT NULL DEFAULT 0
`); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migration 25: add duration: %w", err)
}
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 25",
); err != nil {
return fmt.Errorf("migration 25: set user_version: %w", err)
}
}
if version < 26 { //nolint:mnd
logger.Info("applying migration 26: comprehensive explore schema overhaul")
// Nuke the existing index — we're changing the schema enough
// that a clean rebuild is simpler than trying to migrate in place.
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS explore_index_fts`); err != nil {
return fmt.Errorf("migration 26: drop fts: %w", err)
}
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS explore_index`); err != nil {
return fmt.Errorf("migration 26: drop explore_index: %w", err)
}
// Create the new explore_index with all typed columns.
// No more extra_json — every field that matters has its own column.
if _, err := db.ExecContext(ctx, `
CREATE TABLE explore_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
mbid TEXT NOT NULL,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '',
-- Popularity signals (from LB popularity API, uncapped).
popularity INTEGER NOT NULL DEFAULT 0,
listener_count INTEGER NOT NULL DEFAULT 0,
-- Recording-specific fields.
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid TEXT NOT NULL DEFAULT '',
release_name TEXT NOT NULL DEFAULT '',
-- Release-group-specific fields.
primary_type TEXT NOT NULL DEFAULT '',
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
-- Artist-specific fields.
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
disambiguation TEXT NOT NULL DEFAULT '',
sort_name TEXT NOT NULL DEFAULT '',
-- Personalization flags.
in_library INTEGER NOT NULL DEFAULT 0,
is_similar INTEGER NOT NULL DEFAULT 0,
-- Cross-reference to local library tables. NULL when the
-- entity has no corresponding row in the library.
local_artist_id INTEGER,
local_release_group_id INTEGER,
local_recording_id INTEGER,
-- Set to 1 by indexOneArtist after fetching the full
-- discography (release groups + recordings). Used by
-- indexedArtistMBIDs() so the AddFromCache organic-growth
-- path doesn't shadow artists from later tier 2/3 runs.
discog_fetched INTEGER NOT NULL DEFAULT 0,
-- Schema version — lets us mark rows as stale after schema changes.
schema_version INTEGER NOT NULL DEFAULT 1,
UNIQUE(mbid)
)
`); err != nil {
return fmt.Errorf("migration 26: create explore_index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX idx_explore_index_artist_mbid
ON explore_index(artist_mbid, entity_type, popularity DESC)
`); err != nil {
return fmt.Errorf("migration 26: create artist_mbid index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX idx_explore_index_entity_pop
ON explore_index(entity_type, popularity DESC)
`); err != nil {
return fmt.Errorf("migration 26: create entity_pop index: %w", err)
}
// FTS5 virtual table for text search.
if _, err := db.ExecContext(ctx, `
CREATE VIRTUAL TABLE explore_index_fts USING fts5(
title, artist_name, aliases,
content='explore_index',
content_rowid='id'
)
`); err != nil {
return fmt.Errorf("migration 26: create fts: %w", err)
}
// Triggers to keep FTS in sync with the main table.
if _, err := db.ExecContext(ctx, `
CREATE TRIGGER explore_index_ai AFTER INSERT ON explore_index BEGIN
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
VALUES (new.id, new.title, new.artist_name, new.aliases);
END
`); err != nil {
return fmt.Errorf("migration 26: create ai trigger: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE TRIGGER explore_index_ad AFTER DELETE ON explore_index BEGIN
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
END
`); err != nil {
return fmt.Errorf("migration 26: create ad trigger: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
VALUES (new.id, new.title, new.artist_name, new.aliases);
END
`); err != nil {
return fmt.Errorf("migration 26: create au trigger: %w", err)
}
// Clear the tier metadata so the next build repopulates everything.
if _, err := db.ExecContext(ctx, `DELETE FROM explore_index_meta`); err != nil {
return fmt.Errorf("migration 26: clear meta: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 26",
); err != nil {
return fmt.Errorf("migration 26: set user_version: %w", err)
}
}
if version < 27 { //nolint:mnd
logger.Info("applying migration 27: split explore_cache into http_cache and artist_metadata")
// Create the new tables (no-op if schemas/*.sql already created them).
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS artist_metadata (
mbid TEXT NOT NULL,
source TEXT NOT NULL,
data BLOB NOT NULL,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (mbid, source)
)
`); err != nil {
return fmt.Errorf("migration 27: create artist_metadata: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid
ON artist_metadata(mbid)
`); err != nil {
return fmt.Errorf("migration 27: create artist_metadata index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS http_cache (
url_key TEXT PRIMARY KEY,
response BLOB NOT NULL,
expires_at DATETIME NOT NULL,
entity_mbid TEXT NOT NULL DEFAULT '',
entity_type TEXT NOT NULL DEFAULT ''
)
`); err != nil {
return fmt.Errorf("migration 27: create http_cache: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_http_cache_expires
ON http_cache(expires_at)
`); err != nil {
return fmt.Errorf("migration 27: create http_cache index: %w", err)
}
// Only migrate existing data if explore_cache exists (not a fresh install).
var exploreCacheExists bool
{
row, err := db.QueryContext(ctx,
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='explore_cache'",
)
if err == nil {
if row.Next() {
exploreCacheExists = true
}
_ = row.Close()
}
}
if exploreCacheExists {
// Migrate long-lived sources into artist_metadata.
for _, src := range []string{"audiodb", "fanart", "wikidata-p18", "wikipedia-lead"} {
if _, err := db.ExecContext(ctx, `
INSERT OR IGNORE INTO artist_metadata (mbid, source, data, fetched_at)
SELECT substr(url_key, ?+1), ?, response, COALESCE(expires_at, CURRENT_TIMESTAMP)
FROM explore_cache
WHERE url_key LIKE ?
`, len(src)+1, src, src+":%"); err != nil {
return fmt.Errorf("migration 27: migrate %s: %w", src, err)
}
}
// Migrate remaining (short-lived) entries into http_cache.
if _, err := db.ExecContext(ctx, `
INSERT OR IGNORE INTO http_cache (url_key, response, expires_at, entity_mbid, entity_type)
SELECT url_key, response, expires_at,
COALESCE(mbid, ''), COALESCE(entity_type, '')
FROM explore_cache
`); err != nil {
return fmt.Errorf("migration 27: migrate http_cache: %w", err)
}
// Drop the old table.
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS explore_cache`); err != nil {
return fmt.Errorf("migration 27: drop explore_cache: %w", err)
}
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 27",
); err != nil {
return fmt.Errorf("migration 27: set user_version: %w", err)
}
}
if version < 28 { //nolint:mnd
logger.Info("applying migration 28: repair broken similar_artist_map data from multi-seed labs bug")
// The multi-seed POST form of the labs similar-artists endpoint
// returns mis-grouped results — each seed ends up with a random
// subset of the shared result pool (1-2 artists for most seeds,
// hundreds for a few). Clear the bad rows and invalidate the
// tier4 timestamp so the next index build refetches per-seed.
if _, err := db.ExecContext(ctx,
"DELETE FROM similar_artist_map",
); err != nil {
return fmt.Errorf("migration 28: clear similar_artist_map: %w", err)
}
// Invalidate the tier4 build timestamp so the next startup
// triggers a Tier 4 rebuild. Also clear is_similar markers
// so they get recomputed.
if _, err := db.ExecContext(ctx,
"DELETE FROM explore_index_meta WHERE key = 'tier4_built'",
); err != nil {
// Not fatal — the meta table might not exist yet.
logger.Warn("migration 28: clear tier4_built failed (ok on fresh install)", "error", err)
}
if _, err := db.ExecContext(ctx,
"UPDATE explore_index SET is_similar = 0 WHERE is_similar = 1",
); err != nil {
// Not fatal — explore_index might not exist yet on a
// fresh install where migration 26 just ran.
logger.Warn("migration 28: clear is_similar failed", "error", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 28",
); err != nil {
return fmt.Errorf("migration 28: set user_version: %w", err)
}
}
if version < 29 { //nolint:mnd
logger.Info("applying migration 29: discog_fetched column to track full indexer pipeline coverage")
// Add a discog_fetched column to explore_index. When set to 1
// on an artist row, the indexer's fetchTopRecordings/
// fetchTopReleaseGroups pipeline has run for that artist.
// AddFromCache (the frontend-visit organic-growth path) does
// NOT set this flag — it only writes the artist row plus
// browse-result release groups, so recordings are missing.
//
// indexedArtistMBIDs() filters by discog_fetched=1, so artists
// who only got their row from AddFromCache will still be
// processed by Tier 2/3 and have their full discography fetched
// (including recordings).
if _, err := db.ExecContext(ctx, `
ALTER TABLE explore_index
ADD COLUMN discog_fetched INTEGER NOT NULL DEFAULT 0
`); err != nil {
// May fail if migration runs against a fresh schema (column
// will be created by the schema file instead). Don't bail.
logger.Warn("migration 29: add discog_fetched column failed (ok if fresh)", "error", err)
}
// Backfill: any artist with at least 5 recordings was almost
// certainly hit by fetchTopRecordings (the floor is 5). Use
// this as a heuristic to mark existing data as "discog fetched"
// so the migration is non-disruptive — only the broken
// AddFromCache-only artists get re-indexed.
if _, err := db.ExecContext(ctx, `
UPDATE explore_index
SET discog_fetched = 1
WHERE entity_type = 'artist'
AND mbid IN (
SELECT artist_mbid
FROM explore_index
WHERE entity_type = 'recording'
GROUP BY artist_mbid
HAVING COUNT(*) >= 5
)
`); err != nil {
logger.Warn("migration 29: backfill discog_fetched failed", "error", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 29",
); err != nil {
return fmt.Errorf("migration 29: set user_version: %w", err)
}
}
if version < 30 { //nolint:mnd
logger.Info("applying migration 30: invalidate MB browse-releases cache for recording MBID fix")
// Earlier versions of convertRelease used the MusicBrainz
// track MBID instead of the recording MBID for MBTrack.MBID.
// Tracks and recordings have distinct MBIDs in MB, and the
// local library tags files with the recording MBID, so the
// library-status indicator on album detail pages was always
// showing "not in library" for cached results. Clear the
// http_cache entries for MB browse-releases so the next
// visit refetches with the fixed converter.
if _, err := db.ExecContext(ctx,
"DELETE FROM http_cache WHERE url_key LIKE 'mb:browse:releases:%'",
); err != nil {
// Not fatal — cache might not exist on fresh installs.
logger.Warn("migration 30: clear browse-releases cache failed", "error", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 30",
); err != nil {
return fmt.Errorf("migration 30: set user_version: %w", err)
}
}
return nil
}
// migration2BasenameAndFTS adds the basename column to audio_files,
// backfills it from file_path, creates the basename index, and
// populates the FTS5 search_index table.
func migration2BasenameAndFTS(
@@ -1878,6 +2376,78 @@ func migration17SimilarArtistMap(
return nil
}
// migration18TrackCoverArt recreates the track_metadata VIEW to
// include cover_art_path via a JOIN to the cover_art table.
func migration18TrackCoverArt(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 18: track_metadata cover_art_path")
if _, err := db.ExecContext(
ctx, "DROP VIEW IF EXISTS track_metadata",
); err != nil {
return fmt.Errorf("migration 18: drop view: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`); err != nil {
return fmt.Errorf("migration 18: create view: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 18",
); err != nil {
return fmt.Errorf("could not set user_version to 18: %w", err)
}
logger.Info("migration 18 complete")
return nil
}
// readLibraryDirFromTOML reads the TOML config file and returns
// the Library.DirectoryPath value, or "" if not configured.
func readLibraryDirFromTOML(logger *slog.Logger) string {
@@ -2000,3 +2570,169 @@ func removeLibraryDirFromTOML(logger *slog.Logger) {
"path", configPath,
)
}
// migration19TrackMBIDs recreates the track_metadata VIEW to include
// artist_mbid and release_group_mbid columns via the relational
// chain: recording → artist_credit → artist_credit_artist → artist.
func migration19TrackMBIDs(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 19: track_metadata MBID columns")
if _, err := db.ExecContext(
ctx, "DROP VIEW IF EXISTS track_metadata",
); err != nil {
return fmt.Errorf("migration 19: drop view: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`); err != nil {
return fmt.Errorf("migration 19: create view: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 19",
); err != nil {
return fmt.Errorf("could not set user_version to 19: %w", err)
}
logger.Info("migration 19 complete")
return nil
}
// migration20TrackRecordingMBID recreates the track_metadata VIEW to
// add the recording_mbid column (missed in migration 19).
func migration20TrackRecordingMBID(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 20: track_metadata recording_mbid")
if _, err := db.ExecContext(
ctx, "DROP VIEW IF EXISTS track_metadata",
); err != nil {
return fmt.Errorf("migration 20: drop view: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`); err != nil {
return fmt.Errorf("migration 20: create view: %w", err)
}
// Purge stale ListenBrainz top-recordings cache entries that
// were written before the caaReleaseMbid field was added to
// the LBTopRecording struct. Without this, cached entries
// render without cover art thumbnails in the top tracks section.
if _, err := db.ExecContext(ctx,
"DELETE FROM explore_cache WHERE url_key LIKE 'lb:top-recordings:%'",
); err != nil {
logger.Warn("migration 20: could not purge stale top-recordings cache", "err", err)
// Non-fatal — entries will expire naturally via TTL.
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 20",
); err != nil {
return fmt.Errorf("could not set user_version to 20: %w", err)
}
logger.Info("migration 20 complete")
return nil
}
+35 -6
View File
@@ -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 = ?
+12 -2
View File
@@ -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
+15 -1
View File
@@ -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
@@ -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);
@@ -1,10 +0,0 @@
CREATE TABLE IF NOT EXISTS explore_cache (
url_key TEXT PRIMARY KEY,
response TEXT NOT NULL,
mbid TEXT,
entity_type TEXT,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_explore_cache_expires ON explore_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_explore_cache_mbid ON explore_cache(mbid);
@@ -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);
@@ -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)
);
@@ -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;
+83 -10
View File
@@ -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
}
+20 -9
View File
@@ -26,6 +26,13 @@ type ArtistCreditArtist struct {
CreditID int64
}
type ArtistMetadatum struct {
Mbid string
Source string
Data []byte
FetchedAt time.Time
}
type AudioFile struct {
ID int64
FilePath string
@@ -50,15 +57,6 @@ type CoverArt struct {
MimeType string
}
type ExploreCache struct {
UrlKey string
Response string
Mbid sql.NullString
EntityType sql.NullString
ExpiresAt time.Time
CreatedAt time.Time
}
type FileType struct {
ID int64
Extension string
@@ -69,6 +67,14 @@ type Genre struct {
Name string
}
type HttpCache struct {
UrlKey string
Response []byte
ExpiresAt time.Time
EntityMbid string
EntityType string
}
type Library struct {
ID int64
Name string
@@ -139,6 +145,7 @@ type Recording struct {
Composer sql.NullString
Lyrics sql.NullString
Comment sql.NullString
Mbid sql.NullString
}
type RecordingGenre struct {
@@ -194,4 +201,8 @@ type TrackMetadatum struct {
LibraryID int64
PlayCount int64
LastPlayed sql.NullTime
CoverArtPath string
ArtistMbid string
ReleaseGroupMbid string
RecordingMbid string
}
+24 -2
View File
@@ -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
}
+31 -7
View File
@@ -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
}
@@ -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
}