feat(database): shape the library like files, and shrink the catalog
CI / check (push) Successful in 3m7s
CI / e2e (push) Canceled after 1m45s

Plans 013 and 014, the album page that prompted them, and the smaller
fixes they turned up. Changelog, largest first.

## The local library is shaped like files, not like MusicBrainz

`audio_files` carries its own tags and points at `albums` and
`artists`; `file_genres` is the one real many-to-many. `recordings`,
`release_group_recordings`, `artist_credit`, `artist_credit_artist`,
`recording_genres`, `release_groups` and `release_to_rg` are gone from
the local side, and with them a six-way join in every read, a
`MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine. Measured on a real 25,966-file
library, every many-to-many that model expressed was 1:1 in the data.

- Ownership is a file. `GetFilePathsByRecordingMBIDs`,
  `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
  `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812
  orphaned recordings, 216 release groups and 260 artists that library
  carried are now structurally impossible.
- One projection: every track query selects from the `track_metadata`
  view, one row type, one mapper. Nine hand-rolled copies had drifted
  far enough to report different years on different screens.
- `library_id = 0` means every library, so each list query exists once
  instead of scoped and unscoped with a branch at every call site.
- No migration chain. `sql/schemas/` is the one description of the
  shape; `sql/migrations/`, `applyMigrations` and `schema_migrations`
  are squashed away, along with the drift between them that had sqlc
  generating against a stale schema.
- `database.InsertTestTrack` is the one test seeder; twenty test files
  had been assembling the old FK chain each in its own order.

## The catalog stores its ids as bytes

`explore_index`'s three 36-char MBID columns and its entity-type text
are 16 raw bytes and a small integer. The table and its six indexes go
780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh
install is ~0.6 GB rather than ~1.0 GB.

- `backend/explore/mbid.go` is the only place the encoding is known;
  everything above it speaks dashed strings.
- `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert
  rather than silently returning no rows, since SQLite does not coerce
  between TEXT and BLOB.
- The importer asks the artifact what encoding it carries and converts
  on the way in, so the artifact already published keeps working and no
  format bump is needed.
- `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column
  list, and `TestStoredEncodingRoundTrips` sweeps every read path.

## An album page that says how much of the album is yours

- One question, asked once: is there a file. `filePaths` is filled by a
  single batched lookup when the tracklist settles, and the badge, the
  Play count, the dimmed rows and every menu item read it — replacing
  four claims of decreasing confidence that could show a green tick on
  an album whose every action did nothing.
- Play, Play 7 of 12, or no play button at all.
- `total_tracks` on `explore_index` (~2 bytes over 400,677 release
  groups) and on `audio_files` from tags that have always carried it:
  a complete MBID-matched album now makes no catalog call at all, where
  it used to spend the most expensive request the app makes.
- A merged cluster shows the running order the most releases agree on,
  and the version list marks the release you own rather than standing a
  synthetic entry in for it.
- `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed
  one by a 12-second timer.
- Rows not in the library are dimmed in place (with `aria-disabled`)
  instead of the owned ones wearing a green tick and a legend.

## Caches and cover art get ceilings

- Only the three tiers of a cover are stored; the full-resolution copy
  nothing rendered was 1,134 MB of a 1.4 GB covers directory.
- One artist portrait is downloaded and the rest are remembered as
  URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads.
- `browsedArtBudget` and `httpCacheBudget` bound what an age cannot:
  the same install held art for 5,770 artists in a 1,301-artist
  library.
- `OrphanedArtistImagesJob` joined a bare MBID onto a sharded
  directory, so it deleted the rows that were the only record of the
  files it left behind. `explore.ArtistImageDir` is that layout's one
  definition now.

## The autotag queue asks whether there is work

`tagging_items` was a row per album folder, not a queue, and no query
read the `tag_status` column that held the answer. The four queue
queries ask the files, which matters most where it is least visible:
`startPrefetch` was scoring every album in a tagged library against
MusicBrainz.

## Phantom playlist tracks resolve in place

An M3U8 imported before its files leaves phantom rows; they now match
by path and fall back to position, keep their place in the playlist
when resolved, and pair best-first so two phantoms cannot claim the
same file.

## Playing a track plays the list it is in

Double-click, and Play on a single row's menu, queue the list as
displayed with `startIndex` on that row — the album page and the track
list used to queue one track and discard the album around it. A
multi-row selection still plays exactly itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
This commit is contained in:
2026-08-16 13:58:15 -04:00
co-authored by Claude Opus 5
parent 1128881e8d
commit e7748f1fd5
208 changed files with 10944 additions and 12104 deletions
+43
View File
@@ -0,0 +1,43 @@
-- One row per album in the library.
--
-- This is `release_groups` renamed, and the rename is the point: a
-- release group is a *MusicBrainz* concept and the catalog still has
-- them (`explore_index.entity_type = 'release_group'`). What this
-- table holds is the local thing — the album some files on disk belong
-- to — which may or may not have a catalog counterpart. Calling both
-- of them "release group" is most of why "is this album mine" was a
-- question three different subsystems answered three different ways.
--
-- `artist_credit` is the album artist as tagged ("Various Artists",
-- "A & B"); `artist_id` is the primary artist it resolves to. Album
-- identity is (name, artist_credit), which is what the old
-- UNIQUE(name, album_artist_credit_id) meant with a join in the way.
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
artist_credit TEXT NOT NULL DEFAULT '',
artist_id INTEGER,
mbid TEXT,
-- year is the tagged year of the copy on disk; original_year is
-- MusicBrainz's first-release date when known. For a 2010 remaster
-- of a 1973 album: original_year 1973, year 2010.
year INTEGER,
original_year INTEGER,
cover_art_id INTEGER,
-- Set when the files carried a release MBID but no release-group
-- MBID; a background pass resolves it and clears this.
pending_release_mbid TEXT,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(artist_id) REFERENCES artists(id),
UNIQUE(name, artist_credit)
);
CREATE INDEX IF NOT EXISTS idx_albums_artist_id
ON albums(artist_id);
CREATE INDEX IF NOT EXISTS idx_albums_cover_art_id
ON albums(cover_art_id);
CREATE INDEX IF NOT EXISTS idx_albums_mbid
ON albums(mbid) WHERE mbid IS NOT NULL;
@@ -1,4 +0,0 @@
CREATE TABLE IF NOT EXISTS artist_credit (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL UNIQUE
);
@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS artist_credit_artist (
id integer PRIMARY KEY,
artist_id int NOT NULL,
credit_id int NOT NULL,
FOREIGN KEY(artist_id) REFERENCES artists(id),
FOREIGN KEY(credit_id) REFERENCES artist_credit(id)
);
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id
ON artist_credit_artist(artist_id);
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id
ON artist_credit_artist(credit_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_credit_artist_unique
ON artist_credit_artist(artist_id, credit_id);
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS artist_images (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_artist_images_mbid
ON artist_images(artist_mbid);
-- No index on artist_mbid alone: the UNIQUE index below has it as its
-- leftmost column.
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
ON artist_images(artist_mbid, source, source_url);
@@ -12,4 +12,6 @@ CREATE TABLE IF NOT EXISTS artist_metadata (
PRIMARY KEY (mbid, source)
);
CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid ON artist_metadata(mbid);
-- No index on mbid alone: PRIMARY KEY (mbid, source) already has it as
-- its leftmost column, so a second one costs a write per row and serves
-- no read.
+92 -27
View File
@@ -1,34 +1,92 @@
-- One row per audio file, and the file's tags live on it.
--
-- This table used to be a stub — path, format, a foreign key — with
-- every tag-derived field one join away in `recordings`, which was in
-- turn linked to an album through `release_group_recordings` and to an
-- artist through `artist_credit` + `artist_credit_artist`. That is
-- MusicBrainz's data model, and it is the right model for MusicBrainz:
-- a recording really can appear on many releases and a credit really
-- can list many artists.
--
-- It was the wrong model here, and the library said so. Measured on a
-- real 25,966-file library: **no** recording had more than one file,
-- **no** recording belonged to more than one release group, and 3 of
-- 2,823 credits listed more than one artist. Every many-to-many the
-- schema modelled was 1:1 in the data, and the cost of modelling it
-- anyway was a six-way join in every read, a `MIN(release_group_id)`
-- subquery in eleven queries to collapse a fan-out that never happened,
-- a first-credited-artist subquery in nine more to collapse the other
-- one, and — the reason this changed — a whole class of bugs where a
-- `recordings` row **outlived the file that created it**. Retagging a
-- file created a new recording and abandoned the old one, so the same
-- library carried 812 recordings, 216 release groups and 260 artists
-- with no file behind them, and everything that asked "do I own this"
-- by looking for a metadata row got 129 confident yeses for tracks
-- that could not be played.
--
-- With the tags on the file, ownership is not a rule anyone can forget:
-- the row *is* the file.
CREATE TABLE IF NOT EXISTS audio_files (
id integer PRIMARY KEY,
file_path text NOT NULL UNIQUE,
length_milliseconds int NOT NULL,
file_type_id int NOT NULL,
recording_id int NOT NULL,
sample_rate int NOT NULL DEFAULT 0,
bit_depth int NOT NULL DEFAULT 0,
channels int NOT NULL DEFAULT 0,
bitrate int NOT NULL DEFAULT 0,
file_size int NOT NULL DEFAULT 0,
basename text NOT NULL DEFAULT '',
library_id int NOT NULL DEFAULT 0,
play_count int NOT NULL DEFAULT 0,
last_played datetime,
tag_status TEXT NOT NULL DEFAULT 'untagged'
id INTEGER PRIMARY KEY,
file_path TEXT NOT NULL UNIQUE,
library_id INTEGER NOT NULL DEFAULT 0,
file_type_id INTEGER NOT NULL,
-- Audio properties, read from the file itself.
length_milliseconds INTEGER NOT NULL,
sample_rate INTEGER NOT NULL DEFAULT 0,
bit_depth INTEGER NOT NULL DEFAULT 0,
channels INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0,
-- Tags. `artist_credit` is the credit as tagged ("A feat. B") and is
-- for display; `artist_id` is the primary artist it resolves to, and
-- is what grouping, browsing and the artist page use. Keeping both
-- is what makes the credit table unnecessary: the string is the only
-- thing that was ever read off it.
title TEXT NOT NULL DEFAULT '',
artist_credit TEXT NOT NULL DEFAULT '',
artist_id INTEGER,
album_id INTEGER,
track_number INTEGER,
disc_number INTEGER,
-- The denominator the tag declared: the 12 in "5/12", per disc. It
-- is what lets "do I have all of this album" be answered from disk
-- instead of from MusicBrainz. NULL means the tag did not say, which
-- is a third state and not the same as zero.
total_tracks INTEGER,
year INTEGER,
composer TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
recording_mbid TEXT,
-- Library bookkeeping.
basename TEXT NOT NULL DEFAULT '',
group_key TEXT NOT NULL DEFAULT '',
-- File mtime as a Unix timestamp in seconds, captured at import and
-- compared against the on-disk mtime during a scan to detect files
-- another application retagged in place.
modified_at INTEGER NOT NULL DEFAULT 0,
play_count INTEGER NOT NULL DEFAULT 0,
last_played DATETIME,
tag_status TEXT NOT NULL DEFAULT 'untagged'
CHECK(tag_status IN (
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
)),
group_key TEXT NOT NULL DEFAULT '',
-- File mtime as a Unix timestamp in seconds, captured at import.
-- Compared against the on-disk mtime during a scan to detect files
-- another application retagged in place. 0 means "never recorded"
-- (rows predating migration 47) and is treated as not-stale so an
-- upgrade does not re-import the whole library.
modified_at int NOT NULL DEFAULT 0,
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(library_id) REFERENCES libraries(id)
FOREIGN KEY(library_id) REFERENCES libraries(id),
FOREIGN KEY(artist_id) REFERENCES artists(id),
FOREIGN KEY(album_id) REFERENCES albums(id)
);
CREATE INDEX IF NOT EXISTS idx_audio_files_album_id
ON audio_files(album_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_artist_id
ON audio_files(artist_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
ON audio_files(basename);
@@ -36,10 +94,17 @@ CREATE INDEX IF NOT EXISTS idx_audio_files_group_key
ON audio_files(group_key) WHERE group_key != '';
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
ON audio_files(library_id);
ON audio_files(library_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id
ON audio_files(recording_id);
-- The ownership question, asked by MBID: "is there a *file* with this
-- recording MBID". Nothing may answer it from a metadata table again.
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_mbid
ON audio_files(recording_mbid) WHERE recording_mbid IS NOT NULL;
-- Answers "does this tagging group still contain untagged files" in one
-- seek per group. The autotag queue asks it once per row.
CREATE INDEX IF NOT EXISTS idx_audio_files_untagged_group_key
ON audio_files(group_key) WHERE tag_status = 'untagged';
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
ON audio_files(library_id) WHERE tag_status = 'untagged';
+39 -7
View File
@@ -1,10 +1,25 @@
-- The downloaded MusicBrainz/ListenBrainz catalog.
--
-- MusicBrainz ids are stored as their 16 raw bytes and entity types as
-- small integers, which is a size decision: on a real 2,052,200-row
-- catalog those four columns were 220 MB of a 383 MB table and were
-- carried again in every index keyed on them, and the conversion took
-- the table and its four indexes from 677 MB to 389 MB. See
-- backend/explore/mbid.go, which is the only place that encoding is
-- known -- everything above it speaks dashed strings and entity names.
--
-- The CHECK constraints are what make a mistake loud. SQLite does not
-- coerce between TEXT and BLOB, so a query comparing this column
-- against a 36-character string returns no rows rather than an error;
-- a *write* of one fails here instead, at the insert that made it.
CREATE TABLE IF NOT EXISTS explore_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
mbid TEXT NOT NULL,
entity_type INTEGER NOT NULL,
mbid BLOB NOT NULL CHECK(length(mbid) = 16),
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
artist_mbid BLOB NOT NULL
CHECK(length(artist_mbid) IN (0, 16)),
aliases TEXT NOT NULL DEFAULT '',
-- Popularity signals, derived from the ListenBrainz listens dump.
@@ -13,7 +28,8 @@ CREATE TABLE IF NOT EXISTS explore_index (
-- Recording-specific fields.
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid TEXT NOT NULL DEFAULT '',
caa_release_mbid BLOB NOT NULL DEFAULT x''
CHECK(length(caa_release_mbid) IN (0, 16)),
release_name TEXT NOT NULL DEFAULT '',
-- Release-group-specific fields.
@@ -21,6 +37,13 @@ CREATE TABLE IF NOT EXISTS explore_index (
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
-- How many tracks the release group's canonical release has, so
-- "do I have all of this" is answerable offline for an album the
-- library holds no tags for. Zero means the catalog does not say,
-- which is the same third state the local answer has -- and is what
-- every row carries until a central dump build fills it.
total_tracks INTEGER NOT NULL DEFAULT 0,
-- Artist-specific fields.
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
@@ -46,13 +69,22 @@ CREATE TABLE IF NOT EXISTS explore_index (
UNIQUE(mbid)
);
-- The exact-match tier's two indexes.
--
-- Their predicate is the champion set - the popular rows plus whatever
-- the user owns - and matching it to `ExactMatches`' own WHERE clause is
-- what makes them small. They used to say `popularity > 0`, which on a
-- real 2,052,200-row catalog covered 2,046,645 of them: a full index
-- wearing a partial index's clothes, 101 MB for the pair. Narrowed to
-- the set the tier can actually return, they are 3 MB and the query
-- plan is unchanged (measured, on that catalog).
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
ON explore_index(LOWER(artist_name))
WHERE popularity > 0;
WHERE popularity >= 10000 OR in_library = 1;
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
ON explore_index(caa_release_mbid)
WHERE entity_type = 'release_group' AND caa_release_mbid != '';
WHERE entity_type = 2 AND caa_release_mbid != x'';
CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid
ON explore_index(artist_mbid, entity_type, popularity DESC);
@@ -62,4 +94,4 @@ CREATE INDEX IF NOT EXISTS idx_explore_index_entity_pop
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
ON explore_index(LOWER(title))
WHERE popularity > 0;
WHERE popularity >= 10000 OR in_library = 1;
@@ -0,0 +1,18 @@
-- Genres per file. This is `recording_genres` with the recording taken
-- out of the middle: it is the one many-to-many in the local library
-- that is actually many-to-many (a real library runs about four genre
-- rows per file), which is why it stays a join table when the others
-- did not.
CREATE TABLE IF NOT EXISTS file_genres (
audio_file_id INTEGER NOT NULL,
genre_id INTEGER NOT NULL,
PRIMARY KEY (audio_file_id, genre_id),
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE,
FOREIGN KEY(genre_id) REFERENCES genres(id)
) WITHOUT ROWID;
-- The reverse direction ("which files are in this genre"). The
-- forward direction is served by the primary key, so — unlike the
-- table this replaces — there is no third index restating it.
CREATE INDEX IF NOT EXISTS idx_file_genres_genre_id
ON file_genres(genre_id);
+6 -10
View File
@@ -1,15 +1,11 @@
-- One row per "go find me this", from the moment the user asks until
-- the files are in the library or the attempt is abandoned.
-- One row per folder the user has added as a music library.
--
-- release_mbid / release_group_mbid are the anchor: a request that
-- carries one can be matched against a known tracklist at import time,
-- which is what makes unattended completion safe. Free-text requests
-- (both NULL) are always presented to the user for confirmation.
-- Everything else keyed by library_id means "which of these folders did
-- this come from"; a library_id of 0 in a query means "all of them".
--
-- `expected` caches the anchor's tracklist as JSON so ranking and
-- import do not have to re-resolve it, and so a request survives the
-- explore index being rebuilt underneath it.
-- autotag_warning_acked records that the user has been told what
-- autotagging will do to the files in this folder, which is a decision
-- they made and not something a rescan can rediscover.
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY,
+25
View File
@@ -0,0 +1,25 @@
-- Lyrics for a file, and where they came from.
--
-- These used to be a column on `recordings`, in a table classified
-- `Owned` — data a rescan can rebuild from the files. That was true of
-- lyrics read out of a USLT frame and false of lyrics fetched from
-- LRCLIB, and nothing recorded which was which, so a library with
-- 24,294 of them could not answer how many were free to rebuild and how
-- many were network traffic waiting to happen. `source` answers it.
--
-- `recording_mbid` is carried alongside the file id so a future
-- re-import can re-adopt fetched lyrics without asking LRCLIB again;
-- the file id is the key because untagged files have no MBID and are
-- exactly the ones whose lyrics had to be fetched.
CREATE TABLE IF NOT EXISTS lyrics (
audio_file_id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'tag'
CHECK(source IN ('tag', 'lrclib')),
recording_mbid TEXT,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_lyrics_recording_mbid
ON lyrics(recording_mbid) WHERE recording_mbid IS NOT NULL;
@@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS recording_genres (
id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL,
genre_id INTEGER NOT NULL,
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(genre_id) REFERENCES genres(id),
UNIQUE(recording_id, genre_id)
);
CREATE INDEX IF NOT EXISTS idx_recording_genres_genre_id
ON recording_genres(genre_id);
CREATE INDEX IF NOT EXISTS idx_recording_genres_recording_id
ON recording_genres(recording_id);
@@ -1,19 +0,0 @@
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
artist_credit_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
composer TEXT,
lyrics TEXT,
comment TEXT,
mbid TEXT,
FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id)
);
CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id
ON recordings(artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_recordings_mbid ON recordings(mbid) WHERE mbid IS NOT NULL;
@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS release_group_recordings (
id INTEGER PRIMARY KEY,
release_group_id INTEGER NOT NULL,
recording_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
-- The denominator the file's own tag declared: the 12 in "5/12", per
-- disc. Read off every file at scan and, until now, discarded — so
-- "do I have all of this album" had no local answer and the album
-- page asked MusicBrainz. NULL means the tag did not say, which is
-- a third state and not the same as zero.
total_tracks INTEGER,
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
CREATE INDEX IF NOT EXISTS idx_release_group_recordings_recording_id
ON release_group_recordings(recording_id);
CREATE INDEX IF NOT EXISTS idx_release_group_recordings_release_group_id
ON release_group_recordings(release_group_id);
@@ -1,20 +0,0 @@
CREATE TABLE IF NOT EXISTS "release_groups" (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
cover_art_id INTEGER,
album_artist_credit_id INTEGER,
year INTEGER,
total_tracks INTEGER,
total_discs INTEGER, mbid TEXT, original_year INTEGER, pending_release_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)
);
CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id
ON release_groups(album_artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id
ON release_groups(cover_art_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_mbid ON release_groups(mbid) WHERE mbid IS NOT NULL;
@@ -1,4 +1,11 @@
-- Release MBID -> release-group MBID, captured during a dump import.
--
-- It is empty on an ordinary install and looks droppable for that
-- reason: only a local dump build (`indexbuild`) fills it. The daily
-- incremental refresh reads it to roll per-release listen counts up to
-- the release group they belong to, so an install that has built its
-- own index does need it.
CREATE TABLE IF NOT EXISTS release_to_rg (
release_mbid TEXT PRIMARY KEY,
rg_mbid TEXT NOT NULL
) WITHOUT ROWID;
) WITHOUT ROWID;
@@ -6,5 +6,5 @@ CREATE TABLE IF NOT EXISTS similar_artist_map (
PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
);
CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source
ON similar_artist_map(source_artist_mbid);
-- No index on source_artist_mbid alone: the PRIMARY KEY has it as its
-- leftmost column.
+40 -28
View File
@@ -1,23 +1,42 @@
CREATE VIEW IF NOT EXISTS track_metadata AS
-- The one definition of "a track, with everything a list needs".
--
-- A view is a definition, not data, so it is dropped and recreated on
-- every open rather than carrying a migration alongside it: CREATE VIEW
-- IF NOT EXISTS silently keeps an older database on the old definition,
-- and a migration file restating it would be the second description of
-- the schema the migration rules exist to prevent.
--
-- This projection used to exist **nine times** — four copies in
-- audio_files.sql, two in playlists.sql, two in genres.sql, one in
-- queue.sql — plus this view, which only the raw-SQL search paths used.
-- They had already drifted: this view preferred the album's
-- original_year for `year` and GetAllTracksWithFullMetadata used the
-- track's own, so the same library reported different years on
-- different screens. Every query that wants a track row now selects
-- from here, which is also why there is one row type and one mapper on
-- the Go side instead of nine and a twenty-two-argument function.
DROP VIEW IF EXISTS track_metadata;
CREATE VIEW 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,
af.title,
af.artist_credit AS artist_name,
af.track_number,
af.disc_number,
COALESCE(al.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),
FROM file_genres fg
JOIN genres g ON g.id = fg.genre_id
WHERE fg.audio_file_id = af.id),
''
) AS TEXT) AS genre,
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
COALESCE(rg.year, r.year, 0) AS release_year,
COALESCE(r.composer, '') AS composer,
COALESCE(al.original_year, al.year, af.year, 0) AS year,
COALESCE(al.year, af.year, 0) AS release_year,
af.composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
@@ -28,20 +47,13 @@ CREATE VIEW IF NOT EXISTS track_metadata AS
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
COALESCE(ar.mbid, '') AS artist_mbid,
COALESCE(al.mbid, '') AS release_group_mbid,
COALESCE(af.recording_mbid, '') AS recording_mbid,
af.album_id,
af.artist_id
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;
LEFT JOIN albums al ON al.id = af.album_id
LEFT JOIN artists ar ON ar.id = af.artist_id
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
LEFT JOIN file_types ft ON ft.id = af.file_type_id;