Files
yellowjacket/frontend/src/components/home-view/home-view.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

405 lines
13 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { GetShelves } from '@go/home/service.js';
import { GetAlbumTracks } from '@go/library/library.js';
import type * as home from '@go/home/models.js';
import type * as library from '@go/library/models.js';
import { queueStore } from '@store/queue-store';
import { libraryStore } from '@store/library-store';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
type Shelf = home.Shelf;
/** Icon per shelf kind — a row's reason, at a glance. */
const KIND_ICONS: Record<string, string> = {
'recently-played': 'clock-rotate-left',
'recently-added': 'star',
'most-played': 'repeat',
unplayed: 'box-open',
stale: 'hourglass-half',
artist: 'user',
genre: 'masks-theater',
random: 'shuffle',
};
/**
* The home page: a set of ways *into* the library, rather than another
* view of it.
*
* Everything here is computed by `backend/home`, including the reason
* each row exists, so the rows can change with the user's listening
* without the frontend holding a second opinion about what "on repeat"
* means. This component's job is only to render them and to make a
* cover do the two things a cover should: open the album, or play it.
*/
@customElement('home-view')
export class HomeView extends ViewLifecycleMixin(LitElement) {
@state() private shelves: Shelf[] = [];
@state() private loading = true;
@state() private failed = false;
/** Generation of the library the shelves were built from. */
private builtFromGeneration = -1;
private unsubScan?: () => void;
static override styles = [
designTokens,
css`
:host {
display: block;
height: 100%;
overflow-y: auto;
padding: 24px 20px 40px;
box-sizing: border-box;
}
/* The header brings its own padding, and the host already
has some — without this the title sits indented from the
lede directly beneath it. */
page-header {
margin: -24px -20px 4px;
}
.lede {
margin: 0 0 24px;
font-size: var(--yj-text-md, 13px);
color: var(--yj-text-secondary, #b3b3b3);
}
.shelf {
margin-bottom: 28px;
}
.shelf-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.shelf-title {
font-size: var(--yj-text-xl, 18px);
font-weight: 700;
color: var(--yj-text-primary, #fff);
}
.shelf-sub {
margin: 0 0 10px;
font-size: var(--yj-text-sm, 12px);
color: var(--yj-text-tertiary, #888);
}
.row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 160px;
gap: 14px;
overflow-x: auto;
padding-bottom: 6px;
scrollbar-width: thin;
}
.card {
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
color: inherit;
display: block;
}
.art {
position: relative;
width: 160px;
height: 160px;
border-radius: 6px;
overflow: hidden;
background: var(--yj-bg-surface, #181818);
display: flex;
align-items: center;
justify-content: center;
color: var(--yj-text-tertiary, #888);
}
.art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* An album with no cover used to be a small dim icon on a
surface the same colour as the page, so a shelf read as
having holes in it (H-9) — while the Albums and Artists
grids both drew a letter tile. This is that tile, and the
gradient is what makes it a tile rather than a gap. */
.art .placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
color: var(--yj-text-secondary, #b3b3b3);
font-size: 48px;
font-weight: 300;
user-select: none;
}
.play {
position: absolute;
right: 8px;
bottom: 8px;
width: 38px;
height: 38px;
border: none;
border-radius: 50%;
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.card:hover .play,
.card:focus-within .play {
opacity: 1;
transform: translateY(0);
}
.name {
margin-top: 8px;
font-size: var(--yj-text-md, 13px);
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist {
font-size: var(--yj-text-sm, 12px);
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty {
padding: 48px 20px;
text-align: center;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-lg, 15px);
}
`,
];
protected override onViewActivate(): void {
// Reloaded on arrival rather than kept live: the shelves are a
// judgement about the whole library, so the answer while the
// page is off screen is of no interest to anyone.
void this.load();
// A finished scan changes what every shelf would say, and the
// home page is the view most likely to be sitting open while
// one runs.
this.unsubScan = EventsOn(Events.LibraryScanComplete, () => {
void this.load();
});
this.whileActive(() => {
this.unsubScan?.();
this.unsubScan = undefined;
});
}
/**
* Rebuild when the view is shown again after the library changed.
* Navigation keeps this element alive (see `frontend/index.ts`), so
* without this the shelves would be as old as the session.
*/
override willUpdate(): void {
if (
!this.loading
&& this.builtFromGeneration !== libraryStore.changeGeneration
) {
void this.load();
}
}
private async load(): Promise<void> {
this.builtFromGeneration = libraryStore.changeGeneration;
this.loading = true;
try {
this.shelves = (await GetShelves()) ?? [];
this.failed = false;
} catch (err) {
console.error('Could not build the home page:', err);
this.failed = true;
} finally {
this.loading = false;
}
}
override render() {
return html`
<page-header heading="Home">
<!-- "Shuffle" alone was two different controls with one
name: this one and the transport's shuffle mode.
They were never on screen together until the app
started landing on Home (H-8), and a cached view is
in the accessibility tree either way. -->
<wa-button
slot="actions"
size="small"
appearance="plain"
title="Reshuffle the suggestions"
@click=${() => void this.load()}
>
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle suggestions
</wa-button>
</page-header>
<p class="lede">Somewhere to start listening.</p>
${this.renderBody()}
`;
}
private renderBody() {
if (this.loading && this.shelves.length === 0) {
return html`<div class="empty">Looking through your library\u2026</div>`;
}
if (this.failed) {
return html`<div class="empty">
Could not read your library just now.
</div>`;
}
if (this.shelves.length === 0) {
return html`<div class="empty">
Nothing to suggest yet \u2014 add a music folder under Settings
and the shelves fill in once it has been scanned.
</div>`;
}
return this.shelves.map((shelf) => this.renderShelf(shelf));
}
private renderShelf(shelf: Shelf) {
return html`
<section class="shelf" data-kind=${shelf.kind}>
<div class="shelf-head">
<wa-icon name=${KIND_ICONS[shelf.kind] ?? 'compact-disc'}></wa-icon>
<span class="shelf-title">${shelf.title}</span>
</div>
<p class="shelf-sub">${shelf.subtitle}</p>
<div class="row">
${(shelf.albums ?? []).map((album) => this.renderCard(album))}
</div>
</section>
`;
}
private renderCard(album: library.Album) {
const art = album.CoverArtMedium || album.CoverArtSmall || album.CoverArtPath;
return html`
<div
class="card"
role="button"
tabindex="0"
title="${album.Name}${album.ArtistName ? ` \u2014 ${album.ArtistName}` : ''}"
@click=${() => this.openAlbum(album)}
@keydown=${(e: KeyboardEvent) => this.onCardKey(e, album)}
>
<div class="art">
${art
? html`<img src=${art} alt="" loading="lazy" decoding="async" />`
: html`<div class="placeholder" aria-hidden="true">
${album.Name.charAt(0).toUpperCase()}
</div>`}
<button
class="play"
title="Play this album"
aria-label="Play ${album.Name}"
@click=${(e: Event) => {
e.stopPropagation();
void this.playAlbum(album);
}}
>
<wa-icon name="play"></wa-icon>
</button>
</div>
<div class="name">${album.Name}</div>
${album.ArtistName
? html`<div class="artist">${album.ArtistName}</div>`
: nothing}
</div>
`;
}
private onCardKey(e: KeyboardEvent, album: library.Album): void {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
this.openAlbum(album);
}
private openAlbum(album: library.Album): void {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: album.MBID || '',
albumName: album.Name,
artistName: album.ArtistName,
localAlbumId: album.ID,
},
}),
);
}
private async playAlbum(album: library.Album): Promise<void> {
try {
const tracks = await GetAlbumTracks(album.ID, libraryStore.libraryFilter());
const paths = (tracks ?? []).map((t) => t.FilePath).filter(Boolean);
if (paths.length === 0) return;
queueStore.setQueue(paths, 0, true, {
type: 'album',
id: album.ID,
label: album.Name,
});
} catch (err) {
console.error('Could not play that album:', err);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'home-view': HomeView;
}
}