Files
yellowjacket/frontend/test/components/album-versions.test.ts
T
yonluandClaude Opus 5 e7748f1fd5
CI / check (push) Successful in 3m7s
CI / e2e (push) Canceled after 1m45s
feat(database): shape the library like files, and shrink the catalog
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
2026-08-16 13:58:15 -04:00

436 lines
14 KiB
TypeScript

/**
* When the version dropdown is a choice, and when it is furniture.
*
* A release group routinely has several releases — reissues, regional
* pressings, a remaster — whose tracklists are word for word identical,
* and the synthetic "Your Library" entry is often a third name for the
* same one. Counting *entries* offered a control whose every option
* showed the same rows. The test is distinct tracklists.
*
* The second rule here is about an album you own part of: the page
* draws the *release*, with the tracks you are missing dimmed in place,
* because the missing ones are the information and a tracklist trimmed
* to what is on disk cannot show them at all.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
const MBID = 'rg-0001';
function track(n: number, owned = false) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: owned,
};
}
function release(mbid: string, date: string, trackCount: number, owned = 0) {
return {
mbid,
title: 'Glass Harbour',
date,
status: 'Official',
tracks: Array.from({ length: trackCount }, (_, i) =>
track(i + 1, i < owned),
),
};
}
async function albumWith(
releases: unknown[],
completeness: Record<string, unknown>,
localTracks: unknown[] = [],
): Promise<LitElement> {
stub('explore.Service.BrowseReleases', releases);
stub('library.Library.GetAlbumCompleteness', completeness);
stub('library.Library.GetAlbumTracks', localTracks);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
return el;
}
const UNKNOWN = { owned: 0, expected: 0, known: false, complete: false };
describe('the version dropdown', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('stays hidden when every release has the same tracklist', async () => {
const el = await albumWith(
[
release('rel-1', '2019-04-01', 10),
release('rel-2', '2020-09-01', 10),
release('rel-3', '2021-01-01', 10),
],
UNKNOWN,
);
expect(shadow(el, '#version-select')).toBeNull();
});
it('appears when a release actually differs', async () => {
const el = await albumWith(
[release('rel-1', '2019-04-01', 10), release('rel-2', '2020-09-01', 14)],
UNKNOWN,
);
expect(shadow(el, '#version-select')).not.toBeNull();
});
it('stays hidden for a single release', async () => {
const el = await albumWith([release('rel-1', '2019-04-01', 10)], UNKNOWN);
expect(shadow(el, '#version-select')).toBeNull();
});
/**
* An untagged library copy against the catalog's copy of the very
* same album. This is the one that reached the running app: keys
* were `mbid || title` *per track*, which only helps when both sides
* lack ids — so the local ten (no MBIDs) and the catalog's identical
* ten (with MBIDs) never compared equal, and every owned album grew
* a dropdown the moment its catalog data landed.
*/
it('counts an untagged copy and its catalog twin as one tracklist', async () => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub(
'library.Library.GetAlbumTracks',
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: '',
})),
);
stub('explore.Service.BrowseReleases', [release('rel-1', '2019-04-01', 10)]);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(shadow(el, '#version-select')).toBeNull();
});
/**
* The case that prompted the rule, reported from the running app: a
* local album with no release-group MBID at all. `hydrateLocalOnly`
* synthesises a release from the files, so the entries come out as
* "Your Library" *and* the cluster built from the very same tracks —
* two entries, one tracklist, and under the old length test a
* dropdown whose both options were the same ten songs.
*/
it('stays hidden for a local album with no MBID', async () => {
resetHarness();
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetAlbumCompleteness', {
owned: 10,
expected: 10,
known: true,
complete: true,
});
// No RecordingMBID on any of them, which is what an untagged rip
// looks like and why the fingerprint fallback matters.
stub(
'library.Library.GetAlbumTracks',
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '3:30',
RecordingMBID: '',
})),
);
const el = await fixture<LitElement>('explore-album-details', {
localAlbumId: 7,
albumName: 'Melophobia',
});
await flush();
await el.updateComplete;
expect(shadow(el, '#version-select')).toBeNull();
// The tracklist is still there — this hides a control, not content.
expect(shadowAll(el, '.track-row')).toHaveLength(10);
});
});
describe('an album the library holds part of', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('draws the whole release, with the missing tracks dimmed', async () => {
// Ownership is a *file*, not the catalog row's `inLibrary` flag —
// nine of the twelve recordings resolve to a path, so three rows
// dim. Stating it as flags is what let the page claim an album it
// could not play a note of.
stub(
'library.Library.GetFilePathsByRecordingMBIDs',
Object.fromEntries(
Array.from({ length: 9 }, (_, i) => [`rec-${i + 1}`, [`/music/0${i + 1}.mp3`]]),
),
);
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 9)],
{ owned: 9, expected: 12, known: true, complete: false },
[
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
],
);
const rows = shadowAll(el, '.track-row');
// Twelve rows, not the nine on disk.
expect(rows).toHaveLength(12);
expect(rows.filter((r) => r.classList.contains('unowned'))).toHaveLength(3);
});
it('does not swap in a catalog tracklist when the total is unknown', async () => {
// Without a declared total there is no evidence the local copy is
// short, and preferring the catalog here would quietly replace
// every untagged album's tracklist with a guess.
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 2)],
UNKNOWN,
[
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
{ TrackName: 'Track 2', TrackNumber: 2, DiscNumber: 1, TrackLength: '4:10' },
],
);
expect(shadowAll(el, '.track-row')).toHaveLength(2);
});
});
/**
* Which version you own, by name.
*
* There used to be a synthetic "Your Library" entry standing in for the
* matching release, which hid the thing worth knowing: you could see
* that you owned *a* version but not *which*, while the real release —
* with its date, country and release count — sat underneath under a
* different name. The release is marked instead.
*/
describe('the version you own', () => {
const OWNED_TRACKS = Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: `rec-${i + 1}`,
}));
/** A deluxe edition: a genuinely different track *set*, so it stays
* its own version rather than being folded as a near-duplicate. */
const DELUXE = {
mbid: 'rel-deluxe',
title: 'Glass Harbour (Deluxe)',
date: '2014-05-01',
status: 'Official',
tracks: Array.from({ length: 13 }, (_, i) => ({
position: i + 1,
discNumber: 1,
title: `Track ${i + 1}`,
length: 200000,
mbid: `rec-${i + 1}`,
inLibrary: i < 10,
})),
};
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
async function twoVersions(): Promise<LitElement> {
return albumWith(
[release('rel-2013', '2013-10-08', 10), DELUXE],
UNKNOWN,
OWNED_TRACKS,
);
}
const optionTexts = (el: LitElement) =>
shadowAll(el, '#version-select option').map((o) =>
(o.textContent ?? '').trim().replace(/\s+/g, ' '),
);
it('names the release rather than calling it "Your Library"', async () => {
const options = optionTexts(await twoVersions());
expect(options).toHaveLength(2);
expect(options.some((o) => o.startsWith('Your Library'))).toBe(false);
expect(options.some((o) => o.includes('2013-10-08'))).toBe(true);
});
it('marks the owned one, in words as well as a glyph', async () => {
const owned = optionTexts(await twoVersions()).filter((o) =>
o.includes('in your library'),
);
expect(owned).toHaveLength(1);
expect(owned[0]).toContain('2013-10-08');
expect(owned[0]).toContain('\u2605');
});
it('selects the owned one by default', async () => {
const el = await twoVersions();
const select = shadow<HTMLSelectElement>(el, '#version-select');
expect(select?.value).toBe('cluster:rel-2013');
// Ten rows, not the deluxe's thirteen.
expect(shadowAll(el, '.track-row')).toHaveLength(10);
});
it('says which one it is under the dropdown', async () => {
const el = await twoVersions();
expect(shadow(el, '.version-meta')?.textContent).toContain(
'the version in your library',
);
});
it('still falls back to a synthetic when nothing matches', async () => {
// Local files that are not any known release: there is no version
// name to mark, so the stand-in is still the honest answer.
const el = await albumWith(
[release('rel-2013', '2013-10-08', 10), DELUXE],
UNKNOWN,
OWNED_TRACKS.slice(0, 4),
);
expect(
optionTexts(el).some((o) => o.startsWith('Your Library')),
).toBe(true);
});
});
/**
* Which pressing a merged cluster shows.
*
* Near-duplicates are folded by track *set*, so a resequenced pressing
* — same songs, different running order — merges correctly. But the
* survivor used to be whichever release came first in the browse
* response, which is meaningless ordering: on the album that prompted
* this, one 2021 pressing arrived ahead of eleven 2013 ones and the
* cluster wore the 2021 running order. The user's own files then
* matched no cluster fingerprint, so the page called their copy
* unlinked to MusicBrainz *and* offered a second version whose only
* difference was an ordering almost nothing was pressed in.
*/
describe('a merged cluster', () => {
const resequenced = {
mbid: 'rel-2021',
title: 'Glass Harbour',
date: '2021',
status: 'Official',
tracks: [10, 2, 3, 4, 5, 6, 7, 8, 9, 1].map((n, i) => ({
position: i + 1,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: true,
})),
};
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('shows the order the most releases agree on, not the first seen', async () => {
const el = await albumWith(
// The outlier first, exactly as the real browse returned it.
[
resequenced,
...Array.from({ length: 11 }, (_, i) =>
release(`rel-2013-${i}`, '2013-10-08', 10),
),
],
UNKNOWN,
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: `rec-${i + 1}`,
})),
);
// The consensus order, so the library copy is recognised as it...
const titles = shadowAll(el, '.track-row .track-title').map((t) =>
t.textContent?.trim(),
);
expect(titles[0]).toBe('Track 1');
// ...and there is one version, so no dropdown at all.
expect(shadow(el, '#version-select')).toBeNull();
});
});