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
29 KiB
013 — The database audit
Status: complete (2026-08-16). R1–R10 landed, the album page that prompted the audit with them, and the one part of R5 that ships in the artifact — a per-release-group track denominator — landed as plan 014. The audit below is unchanged from when it was written — the measurements describe the old shape and are the reason for the new one. Branch: none Created: 2026-08-15 Supersedes: the four-part album-page fix sketched in conversation (it survives, reduced, as R1 and R3 below) Related: 010 (owned albums offline), 011 (owned artists' discography), 012 (API call audit), 002 (data lifecycle)
Method
Every number here is measured against the real 25,966-track library
at ~/.local/share/yellowjacket/yj.db (copied read-only), not against
a fixture and not inferred from the code. Where a claim rests on a
capability rather than a count — "sqlc can do X" — it was executed, not
assumed.
The brief: efficiency and simplicity — the minimum required to achieve our featureset, with fewer lines and a smaller database as evidence rather than as the goal. Two named sources of confusion to resolve: local versus remote versions of a thing, files versus tracks, and indexed versus live lookups. One added constraint: avoid hitting APIs by storing intelligently, without a ridiculous base install.
The measurements
The database is 1.00 GB, and 78% of it is one table
| object | size | rows |
|---|---|---|
explore_index |
383 MB | 2,052,200 |
its five indexes + UNIQUE(mbid) |
395 MB | — |
| its two FTS tables | 85 MB | 2,052,200 + 96,451 |
recordings |
38 MB (27 MB of it lyrics) | 26,778 |
lyrics_index |
18 MB | 24,294 |
artist_metadata |
12 MB | 7,673 |
http_cache |
9 MB | 2,930 |
audio_files |
5 MB | 25,966 |
| everything else | < 10 MB | — |
The local library — the part that is the user's — is about 50 MB. The catalog and its indexes are 780 MB.
Inside explore_index, half the bytes are three text columns
| column | bytes | note |
|---|---|---|
mbid |
70 MB | 36-char text; 16 bytes as a blob |
artist_mbid |
70 MB | same, and it is a foreign key in disguise |
caa_release_mbid |
62 MB | same |
entity_type |
18 MB | three distinct values, stored as words |
title / artist_name / release_name |
74 MB | real data |
Five columns are declared, shipped in the artifact, selected in every
query, and empty: aliases (0 rows), sort_name (0),
disambiguation (0), country (69 rows of 2.05 M), artist_type
(72). aliases is additionally a column in both FTS tables, so the
tokenizer indexes nothing, twice.
Two 50 MB indexes have a WHERE clause that excludes 0.3% of rows
idx_explore_title_lower (53 MB) and idx_explore_artist_lower
(48 MB) are WHERE popularity > 0. 2,046,645 of 2,052,200 rows satisfy
that. They are full indexes wearing a partial index's clothes, and they
exist to serve one exact-match tier (ExactMatches,
searchindex.go:1298) that the champion FTS — 96,451 rows, 2 MB —
already covers the popular half of.
The local library models many-to-many relationships that are all 1:1
| claim | measured |
|---|---|
| recordings with more than one file | 0 |
| recordings in more than one release group | 0 |
| artist credits with more than one artist | 3 of 2,823 |
| files sharing a recording | 0 |
recordings (26,778) is one row per file. release_group_recordings
(26,778) is one row per file. artist_credit (2,823) and
artist_credit_artist (2,826) differ by three.
…and it leaks rows that outlive the files
| orphan | count |
|---|---|
recordings with no audio_files row |
812 (218 carry MBIDs) |
release_groups with no file underneath |
216 |
artists credited on no file |
260 |
explore_index rows flagged in_library with no file behind them |
129 recordings, 2 release groups, 1 artist |
That last row is the bug reported today, in the user's own data.
The query surface
| surface | count |
|---|---|
| sqlc queries | 235 (7,850 generated Go lines) |
| raw SQL call sites outside sqlc | 188 |
| bound IPC methods | 272 |
X / XByLibrary query twins |
14 (8 of them exposed as separate bindings) |
| copies of the "one row per file with its metadata" projection | 9, plus the view that already defines it |
mapTrackRow takes 22 positional arguments and is called from 9
places, because each duplicated query generates its own row struct.
The data directory is 8.5 GB — the database is the small part
| path | size | of which |
|---|---|---|
artist-images/ |
5.4 GB | 4,125 MB is candidate images no code path reads; 1,222 MB is primaries + tiers for 5,770 artists in a library with 1,301 |
covers/ |
1.4 GB | 1,134 MB is originals; all three rendered tiers together are 110 MB |
ffmpeg/ |
283 MB | bundled binary |
yj.db |
1.0 GB | above |
yj.db.bak + .bak.20260309 |
452 MB | nothing deletes these |
art caches (cover-art-cache, artist-image-cache) |
81 MB | catalog art, fine |
The 4.1 GB of unreachable artist candidates is the bug CLAUDE.md
records as fixed; this install still carries it, so the janitor jobs
have never run here. Worth confirming they run at all before
declaring that one closed.
The diagnosis
Everything below is downstream of one thing.
There are three different notions of "a track" in this app, and the code keeps asking the wrong one.
- A file — a row in
audio_files. The only thing that is unambiguously yours: it has a path, it plays. - A local entity — a row in
recordings/release_groups/artists. Created by a scan from a file, but with an independent lifetime: nothing deletes it when the file goes, and retagging a file creates a new one and abandons the old (library.go:1722repointsaudio_files.recording_idat a fresh recording;pruneOrphanedMetadataonly runs on the scan's deleted-file branch,library.go:982). This is where the 812 orphans come from — and autotagging is the machine that makes them. - A catalog entity — a row in
explore_index, downloaded, global, identical for every user.
"Is this mine" is asked of (2) almost everywhere, and answered by (1) whenever the user actually does something:
LibraryMBIDIndex.CheckMBIDs(librarymbid.go:64) is literallySELECT mbid FROM recordings WHERE mbid IN (…). It setsinLibraryon every catalog tracklist.pruneStaleLocalCrossReferences(searchindex.go:2480) clearsexplore_index.in_librarywhen therecordingsrow disappears — not when the file does. Hence 129 phantom "you own this" rows.albumLibraryStatus()inexplore-album-details.tsORs four claims of decreasing confidence, none of which is "a file exists".- But
GetFilePathsByRecordingMBIDs, which every action goes through, joinsaudio_files. It is the only one that tells the truth.
So a retagged file leaves behind a recording carrying the old MBID; the catalog matches that MBID; the row renders owned, undimmed, with a Play button; and every action on it fails with "could not be found in your library" — on a fully-tagged library. The user's instinct that the check is fragile is correct, and the fragility is not the live lookup. The live lookup is the only part that is right.
The same confusion explains "files vs tracks" and "local vs remote": tables (2) exist to be a local mirror of the catalog's shape, so a "track" is sometimes a file, sometimes a mirror row, sometimes a catalog row, and the three are joined by MBID — a key that two of the three can lack or lie about.
Findings and recommendations
R1 — Ownership is "a file exists". Say it once, in SQL.
Cheap, immediate, and it fixes the reported bug.
CheckMBIDs'recordingsandrelease_groupsbranches gain a join toaudio_files. (artiststoo, via credit.)pruneStaleLocalCrossReferencestests for a file, not for a local row.pruneOrphanedMetadataruns after the retag path as well as the delete path — or, better, is deleted along with the tables that need it (R2).- One-shot cleanup of the 812/216/260 existing orphans at open.
Effect: 129 lying rows in this library become honest; the class cannot recur while (2) exists.
R2 — Collapse the MusicBrainz-shaped local schema into a file-shaped one
The big one. It is what makes R1 structural rather than a patch.
The local model imitates MusicBrainz's normalization — artist_credit
is an MB concept — for a dataset in which every relationship it
models is 1:1 (measured above). The cost of that imitation:
- 5 tables (
recordings,release_group_recordings,artist_credit,artist_credit_artist,release_to_rg— the last has 0 rows and no schema-file writer) and ~12 indexes. - A 6-way join in every read, including a
MIN(release_group_id)subquery repeated in 11 places to undo a many-to-many that never happens, and a "first credited artist" subquery in 9 to undo another (the row-multiplication bug class documented at length inCLAUDE.md, which serves 3 rows). - An orphan-cleanup subsystem (
GetOrphaned*IDs×3,Count*References×2,pruneOrphanedMetadata) that exists only because these rows can outlive their file — and which does not actually work (812 orphans). - The entire phantom-ownership class above.
Proposed shape:
audio_files id, path, library_id, …, title, track_no, disc_no, year,
composer, comment, artist_credit TEXT, artist_id→artists,
album_id→albums, recording_mbid, modified_at, …
albums id, name, artist_id, mbid, year, original_year,
cover_art_id, total_tracks… (genuinely many files→1)
artists id, name, mbid (genuinely many→1)
genres + file_genres (genuinely many↔many:
107k rows / 26k files)
artist_credit survives as text on the file (display: "A feat.
B") plus artist_id (the primary artist, for grouping) — which is
everything the UI does with it today, minus the join that multiplies
rows.
Effect: a row exists iff a file exists, so R1 becomes a foreign key
rather than a rule anyone can forget. Removes 5 tables, ~12 indexes,
~30 sqlc queries, the orphan subsystem, both repeated subqueries, and
the AUTOMATIC COVERING INDEX SQLite builds on every library load.
Estimated −1,500 to −2,500 lines across backend/library,
backend/database/sql/* and sqlcgen.
Cost: one real migration of user data (not an ADD COLUMN), and it
touches autotag, tagwriter, playlist matching and the explore xref.
This is the item to sequence carefully; everything else is independent
of it.
R3 — One projection, one row type, one mapper
track_metadata (the view) already is the canonical "one row per
file" definition, and only the raw-SQL search paths use it
(search.go, lyrics_search.go). Every sqlc query re-implements it —
9 copies, which have already drifted: the view prefers
rg.original_year for year, GetAllTracksWithFullMetadata uses
r.year. The same library shows a different year depending on which
screen you are on.
Verified, not assumed: sqlc generates cleanly against the view —
SELECT * FROM track_metadata WHERE … yields one TrackMetadatum
struct with correct types (run during this audit).
And the 14 X/XByLibrary twins collapse into one query each:
WHERE (CAST(sqlc.arg(library_id) AS INTEGER) = 0
OR library_id = CAST(sqlc.arg(library_id) AS INTEGER))
Measured cost of the collapse: none. Scoped-with-OR 23 ms, scoped direct 21 ms, unscoped 145 ms over the full 26k rows.
Effect: −14 queries, −8 bindings, −8 frontend branches, 9 row structs → 1, 9 call sites of a 22-argument mapper → 1. Roughly −2,000 generated lines and −300 hand-written ones, and the year inconsistency cannot exist.
R4 — Put explore_index on a diet (~200 MB, no feature loss)
| change | saved |
|---|---|
mbid, artist_mbid, caa_release_mbid as 16-byte blobs |
~110 MB in the table |
…and the same keys in UNIQUE(mbid) (99 MB) and idx_explore_index_artist_mbid (131 MB) |
~70–100 MB |
entity_type → INTEGER |
18 MB + index |
drop aliases, sort_name, disambiguation (0 rows); reconsider country/artist_type (69/72 rows) |
small bytes, real clarity — and one fewer empty FTS column |
make the two LOWER() indexes' partial predicate mean something (popularity >= championPopThreshold OR in_library), or retire the tier onto the champion FTS |
up to 101 MB |
Better still for artist_mbid: it is a foreign key spelled as text.
An integer reference to the artist row is 8 bytes instead of 36 and
makes the 131 MB index a fraction of its size.
Also worth separating: in_library, local_*_id, is_similar and
discog_fetched are personalization stored inside the shipped
catalog table, which is why the artifact import has to merge by
explicit column list and why artist_enrichment had to become its own
table for exactly this reason. Measured: in_library and
local_*_id IS NOT NULL agree on every one of 2,052,200 rows —
they are the same fact stored twice. A library_xref(mbid, kind, local_id) side table would make the catalog table purely the artifact
and delete the merge-by-column-list rule.
R5 — Ask the network less, without a bigger install
Present state (from musicbrainz.go:17-27): search 24 h, entity 7
days, releases 90 days. MusicBrainz entity data changes on the order
of never for the fields we read, and 251 of 2,930 cache rows are
already expired on this install — so a fully-populated artist page
re-fetches itself weekly, forever.
- Raise
cacheTTLEntityto a year (or drop expiry and revalidate in the background). Cost: bytes already stored. Benefit: the steady-state network cost of browsing your own library goes to roughly zero. - Ship a per-release-group
total_tracksin the artifact. 010 correctly rejects shipping tracklists (the per-artist track budget would truncate them, and "Play 7 of 9" for a twelve-track album is a confident lie). But the denominator is one small integer per release group — 400,677 rows, ~2 bytes — and it is exactly whatalbumLibraryStatus/ownership()needs to say complete / incomplete / unknown for a catalog album with no local tags. Tiny, honest, and it does not depend on coverage. - Keep 010's per-user backfill for the tracklists themselves; this does not replace it, it shrinks what it has to cover.
http_cachehas no size bound and no vacuum beyond expiry. Give it a ceiling.
R6 — The 5.3 GB on disk that no feature needs
- 4,125 MB of artist candidate images that nothing reads (the documented bug — but the janitors have not run on this install; verify they run at all).
- Artist images exist for 5,770 artists in a 1,301-artist library. Fetching art for artists you do not own is the same "prefetch everything" instinct as the discography backfill 011 corrected.
- 1,134 MB of cover originals versus 110 MB for all three rendered
tiers. Nothing renders the original; and it is re-derivable from the
audio file itself, which is on disk by definition. Keep
_lgas the largest and drop originals — that is 1.1 GB with no visible change. yj.db.bak(394 MB) andyj.db.bak.20260309(58 MB) accumulate with nothing to clean them.
This is the largest single win available and it does not touch the schema.
R7 — Redundant indexes and dead columns
Five indexes are prefixes of an existing UNIQUE/PK and can be dropped outright (they cost write time on every insert):
idx_recording_genres_recording_id ⊂ UNIQUE(recording_id, genre_id) ·
idx_similar_artist_map_source ⊂ PK(source, similar) ·
idx_artist_credit_artist_artist_id ⊂ UNIQUE(artist_id, credit_id) ·
idx_artist_metadata_mbid ⊂ PK(mbid, source) ·
idx_artist_images_mbid ⊂ UNIQUE(artist_mbid, source, source_url).
Dead data:
recordings.genre— populated on 25,619 rows at every scan and read by nothing. Every genre read goes throughrecording_genres+genres. Write-only column.release_groups.total_tracks/total_discs— 0 rows populated; the feature that needed them put the number onrelease_group_recordingsinstead.release_to_rg— 0 rows, no writer in any schema file.libraries.sqlcarries a doc comment aboutdownload_requests, pasted from another file. Small, but it is the kind of drift the two-file schema rule exists to catch.
R8 — One genuine N+1
mixCandidates (explore/mix.go:181) issues
GetGenreNamesByFilePath per candidate path, inside a loop over
similar artists, inside a loop over seed artists. Twenty seeds × twenty
similar × thirty paths is 12,000 single-row queries for one mix. It is
one query with an IN clause, or one query for the whole weighted set.
(mixSeedProfile above it is the same shape, bounded by seed size.)
Nothing else in the tree matches this pattern — a scan of every query issued inside a loop turned up 72 candidates and this is the only real one.
R9 — The IPC surface has internals in it
Bound and reachable from the frontend today: AcquirePipelineLock,
ReleasePipelineLock, SetJobRegistry, SetScanHooks,
SetRescanHooks, SetRemovalHooks, MusicBrainz, CAALimiter,
PopulateLocalCrossReferences. v3's generator binds every exported
method; these want to be unexported or moved off the service type.
Free lines, and one less way to wedge the app from a console.
R10 — The test DB is not the shape production runs
NewTestDB shares one in-memory connection and leaves readDB nil, so
reader() returns the writer. That is why the read-pool write bug
(documented in CLAUDE.md) reached a user, and why
TestNoWritesOnTheReadPool had to be a tree-walk instead of a test.
Giving the test DB two handles over one shared in-memory file would let
that be an ordinary test.
What I recommend leaving alone
- The download subsystem (requests / downloads / items). Three
tables, clean lifetimes, well argued in the schema comments. The
download_wantstable in this install is the pre-rename name; the rename migration will clear it on next launch. - The champion FTS. 96k rows, 2 MB, a real latency tier.
- The dual write/read handle, WAL, and the persist-writer queues. These are recent, measured, and correct.
- File paths as the frontend's identity for a track. Integer ids
would be cheaper over IPC, but
CLAUDE.md's argument (an index goes stale on re-sort/refilter, a path does not) is right, and the cost is bounded. - Storing lyrics locally (27 MB + 18 MB index for 24k tracks). That is the API-avoidance trade working exactly as intended.
What landed (2026-08-15 / 16)
The third pass: the album page, which is where the report came from
The audit started from a user report — a fully-tagged library saying "not in your library", on hover rather than on click — and R1 fixed the half of that which lives in SQL. The other half was the page: ownership was four claims OR'd into a tick, and the context menu asked the backend per row, as the menu opened.
explore-album-details now resolves the displayed tracklist's file
paths once, from updated(), into one filePaths map that the
badge, the Play count, the dimmed rows and every menu item read. The
synthesised local tracks carry their own FilePath, so a library album
costs no lookup at all; a catalog tracklist costs one batched
GetFilePathsByRecordingMBIDs. catalogScope() no longer returns
'library' here — that was the second complaint in the same report, and
the artist page keeps it because a library-only artist really is
missing sections.
Two bugs fell out of doing it this way, and neither is the one that was reported:
- The render loop. Guarding the lookup on
filePaths(answered) rather than onaskedFor(asked) re-requests every unowned MBID forever, because an unowned MBID never lands in the map. - "No release data available" over a tracklist held in memory.
loadLocalTracksrebuilt the version list only when catalog releases existed, but the "Your Library" entry is synthesised from the local tracks — so the no-releases case was the one case it skipped. Nothing caught it because the old ownership check answered from the local album id and never needed the tracklist to exist.
The second pass: R5–R10
| before | after | |
|---|---|---|
| the two exact-match indexes | 101 MB | 3 MB (predicate narrowed to the champion set; plan unchanged, measured) |
| cover art on disk | original + 3 tiers | 3 tiers — 1,134 MB of a 1.4 GB directory was the original, and nothing rendered it |
| browsed artist art | 90-day expiry, no ceiling | expiry plus a 256 MB budget, oldest evicted first; owned artists never in it |
| MusicBrainz entity TTL | 7 days | 1 year, with a 128 MB ceiling on the response cache |
| redundant indexes | 5 | 0 (3 dropped here, 2 went with their tables) |
| internal methods on the IPC surface | 24 | 0 (//wails:ignore; 272 → 248 bound methods) |
| test DB | one handle, readDB nil |
two handles, the shape production runs |
The catalog line is R4, finished the day after: MBIDs stored as 16 raw bytes and entity types as codes, measured by converting the real 2,052,200-row catalog through the shipped schema. It needed no artifact rebuild — the importer asks the artifact which encoding it carries and converts the older text form on the way in. Plan 014 has the detail.
Two of those repaid immediately. Giving the test database its own
read pool caught three tests writing through it on the first run —
the exact bug class that reached a user as "attempt to write a readonly
database" and that TestNoWritesOnTheReadPool had to walk the source
tree to find. And the artist-image sweep's own test turned out to seed
an artists row with no file and call it owned: the phantom this whole
audit is about, sitting in the fixture of the test that guards it.
One finding in this audit was wrong. aliases, sort_name,
disambiguation, country and artist_type are not dead columns. They
are empty on that install because the artist-enrichment pass had barely
run (which is finding 011's subject), but indexOneArtist writes all
five, and aliases is an FTS column that makes an artist findable by
alias. They stay.
The first pass: R2, carrying R1 and R3
R2 shipped with R1 and R3 inside it, because the collapse made them
free rather than separate work. No migration: fresh installs only, by
the user's decision, so sql/migrations/ went with it.
| before | after | |
|---|---|---|
| local tables | 9 | 5 (audio_files, albums, artists, genres, file_genres) |
| sqlc queries | 235 | 185 |
| generated Go | 7,850 | 6,023 |
| bound IPC methods | 272 | 264 |
| copies of the track projection | 9 + the view | the view |
X/XByLibrary query twins |
14 | 0 |
| migration files + runner | 7 + ~120 lines | 0 |
| net | −5,070 lines across 122 files |
Gone: recordings, release_group_recordings, artist_credit,
artist_credit_artist, pruneOrphanedMetadata's four sweeps,
RemoveLibrary's eight, mapTrackRow's 22 positional arguments, and
340 lines of tagwriter/dbsync.go that existed to relink and then
un-orphan those tables.
Ownership is now a file in every one of the places that used to ask a
metadata table: CheckMBIDs, collectLibraryEntities,
pruneStaleLocalCrossReferences and GetFilePathsByRecordingMBIDs.
Three things found on the way, each written down where it can be hit
again (CLAUDE.md, references/schema-change.md):
- sqlc's parameter rewriter is byte-offset based, so one em dash in
a query comment corrupts generation into
SELECid. sqlc.sliceandsqlc.argdo not compose — slice expansion renumbers, soGetFilePathsByAlbums([1,2], 0)read album id 2 as the library id. Caught by a test, not by a type.release_to_rglooked dead and was not: 0 rows on any ordinary install, because only a localindexbuildfills it, and the daily incremental refresh reads it. Restored.
Verified: make lint (3 configurations), go test ./... plus the
indexbuild and dev tag passes, tsc --noEmit, make ui-test
(768), and a new end-to-end test that scans the real fixture library
and asserts no row outlives its file
(TestScan_FixtureLibraryLeavesNothingBehind).
Sequence
Revised 2026-08-15, after the compatibility constraint was lifted: breaking changes are acceptable and the schema may be squashed. That inverts the order — R2 was last only because of the migration, and it subsumes R1 (ownership becomes a foreign key) and reshapes R3 (the projection is defined over the new tables). Doing R1 and R3 against the old shape first would be work thrown away.
- R2 — the schema collapse, with the rebuild below. It carries R1 and R3 with it.
- R6 — reclaim the 5.3 GB on disk; confirm the janitors run.
- R7 / R9 / R8 / R10 — the small correctness and hygiene items.
- R4 — the
explore_indexdiet. Artifact rebuild + format bump. - R5 — cache TTLs (trivial) and the shipped denominator (rides along with R4's artifact change).
"Break everything" has a floor, and it is not the schema
Reshaping tables freely is fine. Dropping the database is not, and the numbers say so — a wipe-and-rescan would destroy:
| count | why a rescan does not restore it | |
|---|---|---|
files marked user_confirmed |
25,014 | the user's autotag review decisions |
reviewed tagging folders (confirmed/skipped) |
2,109 | ditto, plus every skipped becomes pending again |
rows in recordings.lyrics |
24,294 | an unknown share came from LRCLIB, not from tags — re-fetching them is precisely the API traffic we are trying to avoid |
| playlists / playlist tracks | 22 / 1,917 | Authored; nothing else has them |
So the change ships as a one-shot in-place rebuild: create the new
tables, INSERT … SELECT across, drop the old ones, in a single
transaction at open. Seconds on 26k rows, ~40 lines of SQL, no
migration chain and no rollback path — which is the freedom that was
actually being asked for. sql/migrations/ gets squashed into
sql/schemas/ at the same time (NOTES.md already blesses this
pre-1.0).
Two tables are classified as one Kind and hold another
backend/datamap already encodes what is safe to lose (Owned and
Derived rebuild from the files; Cache is expensive; Authored is
irreplaceable). The audit found two places where the column disagrees
with the table's entry, which is exactly why a wipe looked cheaper
than it is:
audio_files.tag_status— the table isOwned(a projection of the files), butuser_confirmed/user_skipped_permanentareAuthored: a decision the user made that exists nowhere else.recordings.lyrics— the table isOwned, but lyrics fetched by the LRCLIB backfill areCache, and nothing records which of the 24,294 rows came from a tag and which from the network.
The new schema fixes both by construction: lyrics move to their own
MBID-keyed table with a source column (so they survive any rebuild of
the owned tables, and the provenance question becomes answerable), and
tag_status' authored values are carried across explicitly rather than
recomputed.
Expected outcome if all of it lands: database ~1.0 GB → ~0.75 GB, data directory 8.5 GB → ~2.5 GB, sqlc queries 235 → ~180, generated Go 7,850 → ~5,000, bound methods 272 → ~255, and — the part that matters — one definition of "this is mine" that a file either satisfies or does not.
The open questions, answered
- R2's migration — the user's call, and it was "just assume this
new version will only be installed by a new user". So there is no
in-place rebuild and no chain:
sql/schemas/is the whole description. An existingYJ_HOMEdoes not open (itsaudio_fileshasrecording_idand none of the tag columns, andCREATE TABLE IF NOT EXISTScannot add them) — delete and rescan, and rebuild any seed withmake sandbox-seed. - R4's artifact format — no break was needed. The importer asks the artifact what it carries rather than trusting a version, so the published text-form artifact still imports. Plan 014 has it.
- Yes, the janitors run.
Runner.StartcallsRunDueimmediately andlastRunis in-memory, so every launch runs everything due. The 4.1 GB survived becauseOrphanedArtistImagesJobjoined a bare MBID onto a sharded directory — deleting the rows and leaving the files, which is worse than not running — and becauseStrayArtistImageFilesJobdid not exist. Both are fixed; it was a bug report, not a cleanup.
Measured on the finished refactor
| expected | actual | |
|---|---|---|
| sqlc queries | ~180 | 185 |
| generated Go | ~5,000 | 6,024 |
| bound methods | ~255 | 248 |
explore_index + indexes |
— | 780 MB → 405 MB |
The one recommendation not taken
R4's "better still" for artist_mbid: an integer reference to the
artist row (8 bytes) rather than the 16 raw bytes it now stores. It is
a further ~30 MB on idx_explore_index_artist_mbid, and the reason to
stop short is that the artifact carries MBIDs and not local ids, so
the import would have to resolve every row against a table it is in the
middle of filling. Worth its own argument, not a footnote to this one.