Files
yellowjacket/frontend/test/components/album-actions.test.ts
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

228 lines
8.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* An album page you can play from.
*
* `H-13`: no Play, no Shuffle, no Add to queue on the album header, and
* green ticks with no explanation. The reason it is not simply "add three
* buttons" is that this is a **catalog** page — the album on it may be
* entirely the user's, partly theirs, or not theirs at all — and a Play
* button that plays 7 of a release's 40 tracks under a label saying
* "Play" is the page lying about what is owned.
*
* The partial case is the interesting one and it is **only reachable
* here**: it needs a catalog release whose tracklist is partly matched
* against the library, which the fixture library (untagged, no MBIDs,
* no network) cannot produce. The whole-album case was driven by hand
* in the running app.
*/
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, lastArgs } from '@test/support/harness';
import { fixture, shadow, shadowAll, text } from '@test/support/render';
type Version = {
key: string;
label: string;
sublabel: string;
tracks: Array<{
position: number;
discNumber: number;
title: string;
length: number;
mbid: string;
inLibrary: boolean;
}>;
};
function track(n: number, owned: boolean) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: owned,
};
}
/**
* Put a release on the page without the network.
*
* 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,
total: number,
): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
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,
};
Object.assign(el, {
versionEntries: [version],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
const playLabel = (el: LitElement) =>
text(el, '[data-testid="album-play"]');
describe('the album headers primary action', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
// The download actions resolve a target library on mount; without
// this the store awaits an undefined binding result and the whole
// file dies in an unhandled rejection rather than a failed test.
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
});
it('says “Play” when the whole release is owned', async () => {
const el = await withVersion(6, 6);
expect(playLabel(el)).toBe('Play');
expect(shadow(el, '[data-testid="album-shuffle"]')).toBeTruthy();
expect(shadow(el, '[data-testid="album-queue"]')).toBeTruthy();
// No count sentence: there is nothing to qualify.
expect(shadow(el, '.album-owned-note')).toBeNull();
});
it('counts itself when only some of it is owned', async () => {
const el = await withVersion(7, 12);
expect(playLabel(el)).toBe('Play 7 of 12');
expect(text(el, '.album-owned-note')).toBe(
'You have 7 of these 12 tracks.',
);
});
it('offers no play button at all when none of it is owned', async () => {
// A Play button that plays nothing is worse than no Play button;
// the download and want actions are the whole answer here.
const el = await withVersion(0, 12);
expect(shadow(el, '[data-testid="album-play"]')).toBeNull();
expect(shadow(el, '[data-testid="album-shuffle"]')).toBeNull();
expect(shadow(el, '[data-testid="album-queue"]')).toBeNull();
});
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();
expect(calls('library.Library.GetFilePathsByRecordingMBIDs')).toHaveLength(1);
expect(lastArgs('queue.Queue.SetQueue')?.[0]).toHaveLength(7);
});
});
/**
* How a track that is not in the library reads.
*
* It used to be a green tick against the ones that were, plus a legend
* explaining the tick — a positive mark on the common case, which put a
* column of circles down an album you own outright. The comparison that
* settled it is a streaming service dimming what it cannot play: the
* *absence* is the exception, so the absence is what gets marked.
*
* Dimming is a colour, though, so it cannot be the only signal.
* `aria-disabled` is what carries it to anyone not seeing the page.
*/
describe('a track the library does not have', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
});
it('is dimmed, and the owned ones are not', async () => {
const el = await withVersion(3, 12);
const rows = shadowAll(el, '.track-row');
expect(rows).toHaveLength(12);
expect(rows.filter((r) => r.classList.contains('unowned'))).toHaveLength(9);
expect(rows.filter((r) => r.classList.contains('owned'))).toHaveLength(3);
});
it('says so without relying on the colour', async () => {
const el = await withVersion(3, 12);
const rows = shadowAll(el, '.track-row');
expect(rows[0]?.getAttribute('aria-disabled')).toBe('false');
expect(rows[11]?.getAttribute('aria-disabled')).toBe('true');
expect(rows[11]?.getAttribute('aria-label')).toContain('not in your library');
});
/**
* The badge is only on rows that can act on it. An owned track has
* nothing to request, so it carries no mark at all — the undimmed row
* already says it is yours, which is what retired the green tick.
* An unowned one keeps the badge, because it is now a request
* control rather than a decoration, revealed on hover or focus so a
* mostly-owned album is not a column of plus signs.
*/
it('marks only the rows with something left to ask for', async () => {
const el = await withVersion(3, 12);
const rows = shadowAll(el, '.track-row');
const badgeIn = (row: Element) =>
row.querySelector('library-status-indicator');
expect(badgeIn(rows[0]!)).toBeNull();
expect(badgeIn(rows[11]!)).not.toBeNull();
expect(
shadowAll(el, '.track-row library-status-indicator'),
).toHaveLength(9);
expect(shadow(el, '.tracklist-legend')).toBeNull();
});
});