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
245 lines
7.7 KiB
TypeScript
245 lines
7.7 KiB
TypeScript
/**
|
||
* "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 row’s 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');
|
||
});
|
||
});
|