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
+34 -14
View File
@@ -18,7 +18,7 @@ import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness, calls } from '@test/support/harness';
import { stub, flush, resetHarness, calls, lastArgs } from '@test/support/harness';
import { fixture, shadow, shadowAll, text } from '@test/support/render';
type Version = {
@@ -52,6 +52,12 @@ function track(n: number, owned: boolean) {
* The component builds its versions from fetched releases; this reaches
* past that and sets the state the header actually reads, which is the
* only part under test here.
*
* Owning a track means the library has a *file* for it — the page
* resolves the displayed tracklist's paths once and every action, badge
* and dimmed row reads that one answer. So the fixture says which
* tracks have files rather than setting an `inLibrary` flag, which is
* what used to be able to claim ownership of something unplayable.
*/
async function withVersion(
owned: number,
@@ -61,11 +67,20 @@ async function withVersion(
albumName: 'Glass Harbour',
});
const tracks = Array.from({ length: total }, (_, i) => track(i + 1, i < owned));
const paths: Record<string, string[]> = {};
for (const t of tracks.filter((t) => t.inLibrary)) {
paths[t.mbid] = [`/music/${t.mbid}.mp3`];
}
stub('library.Library.GetFilePathsByRecordingMBIDs', paths);
const version: Version = {
key: 'v1',
label: '2019',
sublabel: `${total} tracks`,
tracks: Array.from({ length: total }, (_, i) => track(i + 1, i < owned)),
tracks,
};
Object.assign(el, {
@@ -125,23 +140,28 @@ describe('the album headers primary action', () => {
expect(shadow(el, '[data-testid="album-queue"]')).toBeNull();
});
it('asks for the owned tracks paths once, by the key it owns them by', async () => {
// `perf.m2`'s rule: ask for what the caller uses, once. The caller
// here uses file paths and knows its tracks only as recording
// MBIDs — `MBTrack.localId` is declared and never written by
// anything in the backend.
it('asks for the tracklists paths once, on load, and not on click', async () => {
// `perf.m2`'s rule ask for what the caller uses, once — and the
// ownership rule with it. The page asks about the *whole* displayed
// tracklist when it settles, because whether a track is owned is
// that query's answer and not something to be inferred first. Every
// action, badge and dimmed row then reads the one result, so a
// click asks nothing and cannot fail.
const el = await withVersion(7, 12);
const onLoad = calls('library.Library.GetFilePathsByRecordingMBIDs');
expect(onLoad).toHaveLength(1);
expect(onLoad[0]!.args[0]).toHaveLength(12);
// No empty MBID — an empty string matches every untagged recording
// in the library.
expect(onLoad[0]!.args[0]).not.toContain('');
shadow<HTMLElement>(el, '[data-testid="album-play"]')!.click();
await flush();
const asked = calls('library.Library.GetFilePathsByRecordingMBIDs');
expect(asked).toHaveLength(1);
// Only the owned ones, and no empty MBID — an empty string matches
// every untagged recording in the library.
expect(asked[0]!.args[0]).toHaveLength(7);
expect(asked[0]!.args[0]).not.toContain('');
expect(calls('library.Library.GetFilePathsByRecordingMBIDs')).toHaveLength(1);
expect(lastArgs('queue.Queue.SetQueue')?.[0]).toHaveLength(7);
});
});
@@ -164,8 +164,17 @@ describe('an album the library already holds in full', () => {
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
// A local album's rows carry their file paths, and that is what
// "the library holds this" means now — the page records them as it
// maps the tracks, so nothing has to be asked again later.
stub('library.Library.GetAlbumTracks', [
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
{
TrackName: 'Track 1',
TrackNumber: 1,
DiscNumber: 1,
TrackLength: '3:20',
FilePath: '/music/glass-harbour/01.mp3',
},
]);
});
@@ -253,4 +262,72 @@ describe('an album the library already holds in full', () => {
const badge = shadow(el, 'library-status-indicator');
expect(badge?.getAttribute('status')).toBe('partial');
});
/**
* The denominator the files could not supply.
*
* A great deal of any library declares no track total at all, and
* "unknown" is a third state that must render as neither complete nor
* incomplete — so an album like this used to wear a plain tick no
* matter how much of it was missing. The catalog carries a per-
* release-group total in the artifact for about two bytes a row, and
* that is what fills the gap: the numerator stays local (how many
* distinct track numbers are on disk), only the denominator is
* borrowed.
*/
it('borrows the catalog total when the tags declared none', async () => {
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
totalTracks: 12,
});
stub('library.Library.GetAlbumCompleteness', {
owned: 9,
expected: 0,
known: false,
complete: false,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
const badge = shadow(el, 'library-status-indicator');
expect(badge?.getAttribute('status')).toBe('partial');
expect((badge as unknown as { expected: number }).expected).toBe(12);
expect((badge as unknown as { owned: number }).owned).toBe(9);
});
/**
* And when neither side can total it, nothing is invented: zero means
* "the catalog does not say", which is the same third state the local
* answer has, so the badge stays a plain tick.
*/
it('draws no ring when neither the tags nor the catalog say', async () => {
stub('library.Library.GetAlbumCompleteness', {
owned: 9,
expected: 0,
known: false,
complete: false,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(
shadow(el, 'library-status-indicator')?.getAttribute('status'),
).toBe('in-library');
});
});
@@ -81,10 +81,10 @@ async function expandFirstCard(el: LitElement): Promise<void> {
describe('the album dropdown', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAllAlbums', ALBUMS);
stub('library.Library.GetAllTracks', []);
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetTracks', []);
stub('library.Library.GetAlbumTracks', TRACKS);
stub('library.Library.GetAlbumTracks', TRACKS);
stub('library.Library.GetAlbumTracksByLibrary', TRACKS);
emit(Events.LibraryScanComplete);
});
@@ -154,8 +154,8 @@ describe('the album dropdown', () => {
describe('the albums grid scrolls', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAllAlbums', ALBUMS);
stub('library.Library.GetAllTracks', []);
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetTracks', []);
emit(Events.LibraryScanComplete);
});
@@ -210,6 +210,17 @@ describe('an album the library holds part of', () => {
});
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 },
+12 -12
View File
@@ -76,8 +76,8 @@ describe('the track list says how it is sorted', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetAlbums', []);
emit(Events.LibraryScanComplete);
});
@@ -128,11 +128,11 @@ describe('the track list has a voice for its own state', () => {
beforeEach(() => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetAlbums', []);
});
it('announces the result of a search that matches nothing', async () => {
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetTracks', TRACKS);
emit(Events.LibraryScanComplete);
const el = await fixture<LitElement>('track-list');
@@ -161,10 +161,10 @@ describe('a selectable grid is a listbox, not a row of buttons', () => {
beforeEach(() => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllArtists', ARTISTS);
stub('library.Library.GetAllGenresWithCounts', GENRES);
stub('library.Library.GetAllTracks', []);
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetArtists', ARTISTS);
stub('library.Library.GetGenres', GENRES);
stub('library.Library.GetTracks', []);
stub('library.Library.GetAlbums', []);
emit(Events.LibraryScanComplete);
});
@@ -197,8 +197,8 @@ describe('a clipped value is readable somewhere', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetAlbums', []);
emit(Events.LibraryScanComplete);
});
@@ -240,8 +240,8 @@ describe('the playing row is more than a colour', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetAlbums', []);
emit(Events.LibraryScanComplete);
});
@@ -56,10 +56,10 @@ async function settle(el: LitElement): Promise<void> {
describe('a card grid shows its selection', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAllArtists', ARTISTS);
stub('library.Library.GetAllGenresWithCounts', GENRES);
stub('library.Library.GetAllTracks', []);
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetArtists', ARTISTS);
stub('library.Library.GetGenres', GENRES);
stub('library.Library.GetTracks', []);
stub('library.Library.GetAlbums', []);
// The views read through LibraryController, whose cache is only
// primed by a scan-complete; without it they render nothing and the
// assertion below fails for the wrong reason.
+1 -1
View File
@@ -128,7 +128,7 @@ describe('<library-filter>', () => {
select?.dispatchEvent(new Event('change'));
await flush();
expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([8]);
expect(lastArgs('library.Library.GetTracks')).toEqual([8]);
});
it('picks up a library added while it was on screen', async () => {
@@ -15,10 +15,10 @@ import { fixture, shadow, text } from '@test/support/render';
/** Drop the library store's cache so the list has to fetch. */
async function emptyLibrary(): Promise<void> {
resetHarness();
stub('library.Library.GetAllTracks', []);
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetAllArtists', []);
stub('library.Library.GetAllGenresWithCounts', []);
stub('library.Library.GetTracks', []);
stub('library.Library.GetAlbums', []);
stub('library.Library.GetArtists', []);
stub('library.Library.GetGenres', []);
emit(Events.LibraryScanComplete);
await flush();
}
@@ -40,7 +40,7 @@ describe('<track-list> empty, loading and failed', () => {
});
it('says the query failed, and offers to try again', async () => {
stubFailure('library.Library.GetAllTracks', 'sql: database is locked');
stubFailure('library.Library.GetTracks', 'sql: database is locked');
emit(Events.LibraryScanComplete);
await flush();
@@ -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');
});
});
+1 -1
View File
@@ -132,7 +132,7 @@ describe('home view', () => {
await new Promise((r) => setTimeout(r, 0));
expect(seen).toEqual([]);
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1]);
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1, 0]);
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
['/music/1.mp3', '/music/2.mp3'],
0,
@@ -67,7 +67,7 @@ describe('<queue-panel> when closed', () => {
describe('<track-list> roving tabindex', () => {
beforeEach(() => {
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetTracks', TRACKS);
});
it('offers exactly one tab stop, however many rows there are', async () => {
@@ -132,8 +132,8 @@ describe('<explore-view> badges', () => {
stub('explore.Service.GetThumbnail', '');
stub('explore.Service.GetArtistImageURL', '');
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetAllTracks', []);
stub('library.Library.GetAlbums', []);
stub('library.Library.GetTracks', []);
await withRequests([]);
});
@@ -0,0 +1,201 @@
/**
* The phantom resolver showed the same library track twice.
*
* Its right-hand panel renders two lists one under the other — the
* scored candidates and the library search results — and a search for
* the obvious title returns exactly what scoring already found. So the
* track appeared once with a score and once without, and double-clicking
* either did the same thing.
*
* The second half is what a match *means*: one library file cannot stand
* in for two unmatched tracks, or applying adds it to the playlist
* twice. `FindPhantomMatches` has always claimed candidates on the
* auto-match path; the manual path had no such rule.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import type { LitElement } from 'lit';
import '@components/phantom-resolver/phantom-resolver';
import { flush, stub } from '@test/support/harness';
import { fixture } from '@test/support/render';
interface Candidate {
FilePath: string;
Title: string;
Artist: string;
Album: string;
Duration: string;
Score: number;
}
function candidate(
path: string,
title: string,
score = 0.5,
): Candidate {
return {
FilePath: path,
Title: title,
Artist: 'An Artist',
Album: 'An Album',
Duration: '200000',
Score: score,
};
}
const PHANTOM_A = '/music/gone/one.mp3';
const PHANTOM_B = '/music/gone/two.mp3';
/** Mount the dialog with two unmatched tracks and no auto-matches. */
async function open(
candidates: Candidate[],
searchResults: Candidate[] = [],
): Promise<HTMLElement & { updateComplete: Promise<unknown> }> {
stub('playlist.Service.FindPhantomMatches', {
AutoMatched: [],
Unmatched: [PHANTOM_A, PHANTOM_B],
});
stub('playlist.Service.GetPhantomCandidates', candidates);
stub('playlist.Service.SearchLibrary', searchResults);
const el = await fixture<
LitElement & { show(id: number, tracks: unknown[]): void }
>('phantom-resolver');
el.show(1, [
{ FilePath: PHANTOM_A, Title: 'One', Phantom: true },
{ FilePath: PHANTOM_B, Title: 'Two', Phantom: true },
]);
await flush();
await el.updateComplete;
await flush();
await el.updateComplete;
return el;
}
function candidateRows(el: HTMLElement): HTMLElement[] {
return [
...(el.shadowRoot?.querySelectorAll<HTMLElement>('.candidate-item') ?? []),
];
}
/** The dialog renders the file path as each row's `title`. */
function rowPaths(el: HTMLElement): string[] {
return candidateRows(el).map((r) => r.getAttribute('title') ?? '');
}
describe('the phantom resolver', () => {
beforeEach(() => {
stub('playlist.Service.ResolvePhantomTracks', null);
stub('playlist.Service.RemovePhantomTracks', null);
});
it('lists a search result the candidates already show only once', async () => {
const shared = candidate('/music/have/one.mp3', 'One', 0.7);
const el = await open([shared], [shared, candidate('/music/have/x.mp3', 'X', 0)]);
// Type into the search box and let the debounce fire.
const input = el.shadowRoot?.querySelector<HTMLInputElement>(
'.search-input',
);
expect(input, 'the search box is rendered').toBeTruthy();
input!.value = 'one';
input!.dispatchEvent(new InputEvent('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 500));
await flush();
await el.updateComplete;
const paths = rowPaths(el);
expect(paths.filter((p) => p === shared.FilePath)).toHaveLength(1);
expect(paths).toContain('/music/have/x.mp3');
});
it('matches a candidate from the keyboard, not only a double-click', async () => {
const el = await open([candidate('/music/have/one.mp3', 'One', 0.7)]);
const row = candidateRows(el)[0];
expect(row, 'a candidate row is rendered').toBeTruthy();
expect(row!.getAttribute('role')).toBe('button');
expect(row!.getAttribute('tabindex')).toBe('0');
row!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
await el.updateComplete;
// Matching the first phantom advances to the second, whose row
// shows the check mark for the one just confirmed.
const matched = el.shadowRoot?.querySelectorAll('.phantom-item.matched');
expect(matched).toHaveLength(1);
});
it('will not spend one library file on two unmatched tracks', async () => {
const only = candidate('/music/have/one.mp3', 'One', 0.7);
const el = await open([only]);
candidateRows(el)[0]!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
await el.updateComplete;
await flush();
await el.updateComplete;
// The second phantom is selected now and offered the same file,
// which is already standing in for the first.
const row = candidateRows(el)[0];
expect(row!.classList.contains('claimed')).toBe(true);
expect(row!.getAttribute('aria-disabled')).toBe('true');
row!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
await el.updateComplete;
// Still one confirmed match, not two.
expect(
el.shadowRoot?.querySelectorAll('.phantom-item.matched'),
).toHaveLength(1);
});
it('gives the auto-match disclosure a keyboard-reachable control', async () => {
stub('playlist.Service.FindPhantomMatches', {
AutoMatched: [
{
PhantomPath: PHANTOM_A,
PhantomTitle: 'One',
Candidate: candidate('/music/have/one.mp3', 'One', 0.95),
},
],
Unmatched: [PHANTOM_B],
});
stub('playlist.Service.GetPhantomCandidates', []);
const el = await fixture<
LitElement & { show(id: number, tracks: unknown[]): void }
>('phantom-resolver');
el.show(1, [{ FilePath: PHANTOM_A, Title: 'One', Phantom: true }]);
await flush();
await el.updateComplete;
const header = el.shadowRoot?.querySelector('.auto-match-header');
expect(header?.tagName).toBe('BUTTON');
expect(header?.getAttribute('aria-expanded')).toBe('false');
// aria-controls has to name an element that is in the DOM, so the
// list renders collapsed rather than not at all.
const controls = header?.getAttribute('aria-controls');
expect(controls).toBeTruthy();
expect(el.shadowRoot?.getElementById(controls!)).toBeTruthy();
});
});
@@ -0,0 +1,271 @@
/**
* Playing a track plays the list it is in.
*
* Double-clicking a row — or picking Play from its context menu — used
* to call `SetQueue([thatOnePath], 0)` on the album page, in the track
* list and in the two playlist views' menus: the queue became one
* track, the rest of the album was discarded, and playback stopped at
* the end of it. What a player means by activating a row is "start
* here", and the here is a position in the list on screen.
*
* So the queue is the *displayed* list and `startIndex` is the row.
* Two rules ride along and are what these tests are mostly for:
*
* - The album page's list is the tracks it has files for, so the index
* is into that and not into the tracklist that includes the dimmed
* rows — off-by-however-many-you-do-not-own is silent, it just plays
* the wrong song.
* - A menu asks how much is selected. One row means "from here"; an
* explicit multi-row selection means play exactly those, which is
* the one case where a queue of the selection is what was asked for.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import '@components/track-list/track-list';
import '@components/playlist-details/playlist-details';
import { stub, flush, resetHarness, lastArgs } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
/** What `Queue.SetQueue` was last asked to play, and from where. */
function queued(): { paths: string[]; startIndex: number } {
const args = lastArgs('queue.Queue.SetQueue');
if (!args) throw new Error('nothing was queued');
return {
paths: args[0] as string[],
startIndex: args[1] as number,
};
}
function dblclick(el: Element): void {
el.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, composed: true }),
);
}
// =====================================================================
// The album page
// =====================================================================
function albumTrack(n: number, owned: boolean) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: owned,
};
}
/**
* A release on the page without the network, owned every `nth` track.
* The fixture says which tracks have *files*, because that is the one
* question the page asks about ownership.
*/
async function album(total: number, ownedMbids: string[]): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
});
const tracks = Array.from({ length: total }, (_, i) =>
albumTrack(i + 1, ownedMbids.includes(`mbid-${i + 1}`)),
);
const paths: Record<string, string[]> = {};
for (const mbid of ownedMbids) paths[mbid] = [`/music/${mbid}.mp3`];
stub('library.Library.GetFilePathsByRecordingMBIDs', paths);
Object.assign(el, {
versionEntries: [
{ key: 'v1', label: '2019', sublabel: `${total} tracks`, tracks },
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
describe('double-clicking a track on the album page', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
});
it('queues the album and starts on that track', async () => {
const el = await album(6, ['mbid-1', 'mbid-2', 'mbid-3', 'mbid-4', 'mbid-5', 'mbid-6']);
dblclick(shadowAll(el, '.track-row')[2]!);
await flush();
expect(queued()).toEqual({
paths: [1, 2, 3, 4, 5, 6].map((n) => `/music/mbid-${n}.mp3`),
startIndex: 2,
});
});
it('indexes into what is owned, not into the rows on screen', async () => {
// Tracks 2, 5 and 6 have files; the other three rows are dimmed and
// are not in the queue at all. Row 5 is therefore the *second*
// thing that will play, and an index taken from the row would start
// this album past its end.
const el = await album(6, ['mbid-2', 'mbid-5', 'mbid-6']);
dblclick(shadowAll(el, '.track-row')[4]!);
await flush();
expect(queued()).toEqual({
paths: ['/music/mbid-2.mp3', '/music/mbid-5.mp3', '/music/mbid-6.mp3'],
startIndex: 1,
});
});
it('does nothing at all on a row with no file behind it', async () => {
const el = await album(6, ['mbid-2']);
dblclick(shadowAll(el, '.track-row')[0]!);
await flush();
expect(lastArgs('queue.Queue.SetQueue')).toBeUndefined();
});
});
// =====================================================================
// The track list
// =====================================================================
const LIST = Array.from({ length: 12 }, (_, i) => ({
FilePath: `/music/track-${i}.mp3`,
TrackName: `Track ${i}`,
ArtistName: 'An Artist',
Album: 'An Album',
Duration: 180,
})) as never[];
describe('double-clicking a row in the track list', () => {
let el: LitElement;
beforeEach(async () => {
resetHarness();
localStorage.removeItem('track-list-column-widths');
el = await fixture<LitElement>('track-list', { externalTracks: LIST });
el.style.display = 'block';
el.style.height = '600px';
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 60));
});
it('queues the list as displayed and starts on that row', async () => {
const rows = shadowAll(el, '.track-row');
const row = rows.find((r) => r.getAttribute('data-index') === '3');
dblclick(row!);
await flush();
const { paths, startIndex } = queued();
expect([paths.length, paths[startIndex]]).toEqual([
12,
'/music/track-3.mp3',
]);
});
});
// =====================================================================
// A playlist's context menu
// =====================================================================
function playlistTracks(n: number) {
return Array.from({ length: n }, (_, i) => ({
ID: i + 1,
FilePath: `/music/track-${i}.mp3`,
Title: `Track ${i}`,
Artist: 'An Artist',
Album: 'An Album',
Duration: 180000,
Phantom: false,
}));
}
describe('Play from a playlist rows context menu', () => {
let el: LitElement;
beforeEach(async () => {
resetHarness();
stub('playlist.Service.GetPlaylistTracks', playlistTracks(8));
stub('playlist.Service.GetAllPlaylists', []);
el = await fixture<LitElement>('playlist-details', {
playlistId: 1,
playlistName: 'A playlist',
});
el.style.display = 'block';
el.style.height = '600px';
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 60));
});
/** Right-click a row, then click the menu's Play item. */
async function playFromMenu(index: number): Promise<void> {
const row = shadowAll(el, '.track-item').find(
(r) => r.getAttribute('data-index') === String(index),
);
row!.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, composed: true }),
);
await el.updateComplete;
const items = shadowAll<HTMLElement>(el, 'wa-dropdown-item');
const play = items.find((i) => i.textContent?.trim().startsWith('Play') &&
!i.textContent.includes('Next'));
play!.click();
await flush();
}
it('starts the playlist from the row that was clicked', async () => {
await playFromMenu(5);
const { paths, startIndex } = queued();
expect([paths.length, startIndex]).toEqual([8, 5]);
});
it('plays only the selection when several rows are selected', async () => {
// The one case where a queue of the selection is what was asked
// for: the user said which tracks, not where to start.
const rows = shadowAll(el, '.track-item');
const click = (i: number, modifiers: MouseEventInit) =>
rows
.find((r) => r.getAttribute('data-index') === String(i))!
.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true, ...modifiers }),
);
click(1, {});
click(4, { ctrlKey: true });
await el.updateComplete;
await playFromMenu(4);
expect(queued().paths).toEqual([
'/music/track-1.mp3',
'/music/track-4.mp3',
]);
});
});
+4 -4
View File
@@ -117,10 +117,10 @@ const TAGS = [
*/
function stubEmptyBackend(): void {
const emptyLists = [
'library.Library.GetAllTracks',
'library.Library.GetAllAlbums',
'library.Library.GetAllArtists',
'library.Library.GetAllGenresWithCounts',
'library.Library.GetTracks',
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
'library.Library.GetAllLibrariesWithTrackCounts',
'playlist.Service.GetAllPlaylists',
'playlist.Service.GetAllPlaylistsWithTracks',
@@ -233,10 +233,10 @@ const CACHED_VIEWS = [
* binding resolves undefined, which is not what Go sends. */
function stubEmptyBackend(): void {
for (const path of [
'library.Library.GetAllTracks',
'library.Library.GetAllAlbums',
'library.Library.GetAllArtists',
'library.Library.GetAllGenresWithCounts',
'library.Library.GetTracks',
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
'library.Library.GetAllLibrariesWithTrackCounts',
'playlist.Service.GetAllPlaylists',
'playlist.Service.GetAllPlaylistsWithTracks',