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
436 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|