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
@@ -0,0 +1,244 @@
/**
* "Track Details" and "Add to Playlist" on Explore's owned tracks.
*
* The library-side lists have had this item for as long as the dialog
* has existed; Explore's tracklists — the album page's and the artist
* page's top tracks — had Play, Add to Queue and Play Next and stopped
* there. The reason it is worth a test rather than being one more
* `<wa-dropdown-item>` is that the two sides hold different things: a
* library row *is* a `library.Track`, and an Explore row is the
* catalog's, which can name a file only through its recording MBID.
*
* So what this pins is the join. Both items appear only for a track the
* user owns (an unowned one has no file, and both are about a file):
* Track Details resolves MBID → path → the library's own track and
* hands *that* to the dialog, and Add to Playlist resolves the same
* path and hands it to the shared picker.
*/
import type { LitElement } from 'lit';
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/explore-album-details/explore-album-details';
import { emit, flush, stub } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { showTrackDetailsForPath } from '@utils/track-details-opener';
import type { TrackDetails } from '@components/track-details/track-details';
const ALBUM_TRACKS = 'library.Library.GetAlbumTracks';
const COMPLETENESS = 'library.Library.GetAlbumCompleteness';
const FILE_PATHS = 'library.Library.GetFilePathsByRecordingMBIDs';
const ALL_TRACKS = 'library.Library.GetTracks';
const LOOKUP_RG = 'explore.Service.LookupReleaseGroup';
const BROWSE_RELEASES = 'explore.Service.BrowseReleases';
const PATH = '/music/an-album/01.flac';
const MBID = 'rec-1';
/** One row as `GetAlbumTracks` returns it. */
const albumTrack = {
ID: 1,
FilePath: PATH,
TrackName: 'A Song',
TrackNumber: 1,
DiscNumber: 1,
TrackLength: '3:00',
RecordingMBID: MBID,
};
/** The same track as the library's own model, which is what the dialog wants. */
const libraryTrack = {
ID: 1,
FilePath: PATH,
Title: 'A Song',
Artist: 'An Artist',
Album: 'An Album',
CoverArtPath: '/covers/a.jpg',
CoverArtSmall: '/covers/a-64.jpg',
CoverArtMedium: '/covers/a-256.jpg',
CoverArtLarge: '/covers/a-512.jpg',
};
/**
* Mount the album page as a library-only album, which is the cheapest
* route to a rendered tracklist: no MBID means it hydrates entirely
* from `GetAlbumTracks` and asks the catalog nothing.
*/
async function albumPage() {
return fixture('explore-album-details', { localAlbumId: 7 });
}
/** Open the context menu on the first track row and return its items. */
async function openTrackMenu(el: LitElement) {
const row = shadow(el, '.track-row');
expect(row, 'a track row is rendered').not.toBeNull();
row!.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true }),
);
await flush();
await el.updateComplete;
return shadowAll(el, '.context-menu-panel wa-dropdown-item');
}
/** The track the page's `<track-details>` was opened on, once it has one. */
async function dialogTrack(
el: LitElement,
attempts = 100,
): Promise<{ FilePath: string } | null> {
for (let i = 0; i < attempts; i += 1) {
const dialog = shadow<TrackDetails>(el, 'track-details') as unknown as {
track?: { FilePath: string } | null;
} | null;
if (dialog?.track) return dialog.track;
await flush();
}
return null;
}
/** The file paths handed to the playlist picker, once it is mounted. */
async function pickerPaths(
el: LitElement,
attempts = 100,
): Promise<string[] | null> {
for (let i = 0; i < attempts; i += 1) {
const picker = shadow(el, 'playlist-picker') as unknown as {
filePaths?: string[];
} | null;
if (picker?.filePaths?.length) return picker.filePaths;
await flush();
}
return null;
}
const labels = (items: Element[]) =>
items.map((i) => (i.textContent ?? '').trim());
describe('Explore track details', () => {
beforeEach(() => {
stub(ALBUM_TRACKS, [albumTrack]);
stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 });
stub(FILE_PATHS, { [MBID]: [PATH] });
stub(ALL_TRACKS, [libraryTrack]);
});
/**
* `libraryStore` fetches at import and caches the empty list the
* shared setup stubs, for the life of the browser session — so a test
* that wants tracks in it has to say so. A scan-complete event is how
* the app itself invalidates that cache.
*/
async function primeLibrary() {
emit(Events.LibraryScanComplete);
await flush();
}
it('offers Track Details on an owned track', async () => {
const el = await albumPage();
const items = await openTrackMenu(el);
expect(labels(items)).toContain('Track Details');
});
it('does not offer it on a track the library does not have', async () => {
// A catalog album the user owns nothing of: the tracklist renders
// from the browse, and every row is unowned.
stub(ALBUM_TRACKS, []);
stub(LOOKUP_RG, {
mbid: 'rg-1',
title: 'An Album',
artistCredit: 'An Artist',
firstReleaseDate: '1994',
primaryType: 'Album',
});
stub(BROWSE_RELEASES, [
{
mbid: 'rel-1',
title: 'An Album',
date: '1994',
country: 'GB',
tracks: [
{
mbid: 'rec-2',
title: 'Another Song',
position: 1,
length: 180000,
discNumber: 1,
inLibrary: false,
},
],
},
]);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: 'rg-1',
});
const items = await openTrackMenu(el);
expect(labels(items)).not.toContain('Track Details');
expect(
labels(items).some((l) => l.startsWith('Add to Playlist')),
'nothing to add to a playlist when there is no file',
).toBe(false);
// The one item a track nobody owns still has, which is what makes
// the assertion above about the gate rather than about an empty
// menu that never opened.
expect(labels(items)).toContain('View on MusicBrainz');
});
it('opens the dialog on the library track behind the row', async () => {
await primeLibrary();
const el = await albumPage();
const items = await openTrackMenu(el);
const details = labels(items).indexOf('Track Details');
items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
// Polled rather than counted: the opener is three awaits deep — the
// path lookup, the store's tracks, and the dynamic `import()` of
// the dialog chunk — and a chunk fetch is the one of the three
// whose cost depends on what else the suite is doing.
const shown = await dialogTrack(el);
expect(shown?.FilePath).toBe(PATH);
});
it('opens the playlist submenu on the rows own file', async () => {
const el = await albumPage();
const items = await openTrackMenu(el);
// Its label carries the submenu arrow, so match the prefix.
const add = labels(items).findIndex((l) => l.startsWith('Add to Playlist'));
expect(add, 'the submenu item is in the menu').toBeGreaterThan(-1);
items[add]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
// Resolved by MBID at the moment the submenu opens, so the picker
// is not there on the first tick the way a library list's is.
const picker = await pickerPaths(el);
expect(picker).toEqual([PATH]);
});
it('reports a path with no library track rather than opening empty', async () => {
stub(ALL_TRACKS, []);
await primeLibrary();
const outcome = await showTrackDetailsForPath(
() => undefined,
PATH,
() => undefined,
);
expect(outcome).toBe('not-in-library');
});
});