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
@@ -250,7 +250,7 @@ export class ArtistDetails extends LitElement {
try {
const albums =
await this.libraryCtrl.getAlbumsByArtist(
this.artistId,
this.artistName,
);
const result = albums ?? [];
@@ -12,7 +12,6 @@ import type {
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import {
GetAlbumsByArtist,
GetAlbumsByArtistByLibrary,
GetFilePathsByAlbums,
} from '@go/library/library.js';
import * as library from '@go/library/models.js';
@@ -1057,12 +1056,7 @@ export class ArtistsView
this.libraryCtrl.selectedLibraryId;
const albums = await list(
libId !== null
? GetAlbumsByArtistByLibrary(
artist.ID,
libId,
)
: GetAlbumsByArtist(artist.ID),
GetAlbumsByArtist(artist.Name, libId ?? 0),
);
const byAlbum = await dict(
@@ -1034,6 +1034,39 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
font-size: 0.9rem;
}
/* The "nothing to tag" state. A finished queue is the
normal resting state of this page on a tagged library,
not a failure, so it gets a settled look rather than
the bare sentence the other .empty slots use. */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
text-align: center;
padding: 4rem 1.5rem;
color: var(--yj-text-secondary, #b3b3b3);
}
.empty-state wa-icon {
font-size: 2.5rem;
color: var(--yj-text-tertiary, #888);
}
.empty-state h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--yj-text-primary, #f1f3f5);
}
.empty-state p {
margin: 0;
max-width: 34ch;
font-size: 0.9rem;
line-height: 1.5;
}
.error {
background: rgba(200, 90, 90, 0.15);
color: #f99;
@@ -2918,6 +2951,33 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
`;
}
/** The queue came back with nothing in it — every folder the
* scan found already carries tags. This is the resting state
* of the page on a tagged library, so it says so in words
* rather than leaving the skeleton up: an endless shimmer reads
* as a page that is still working. */
private renderEmptyQueue(): TemplateResult {
const filtered = this.currentLibraryFilter !== null;
return html`
<div class="main">
<div class="empty-state">
<wa-icon name="circle-check"></wa-icon>
<h3>Nothing to tag</h3>
<p>
${filtered
? html`No untagged files in the selected library.
Switch the library filter, or add new music
and it will appear here after the next scan.`
: html`No untagged files. Add new music to your
library and it will appear here after the
next scan.`}
</p>
</div>
</div>
`;
}
private renderMain() {
// A scoring error on the selected folder surfaces as an error,
// not an endless skeleton.
@@ -2939,14 +2999,29 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
if (!this.current) {
// A folder list that failed to load is not an empty
// queue. Without this the one message the page cannot
// honestly show — "there is nothing to tag" — is exactly
// what a failed ListPendingFolders renders.
if (this.folders.length === 0 && this.errorMessage) {
return html`
<div class="main">
<div class="error">
<span>${this.errorMessage}</span>
<button @click=${this.onDismissError}>Dismiss</button>
</div>
</div>
`;
}
if (this.folders.length === 0) {
return this.renderEmptyQueue();
}
return html`
<div class="main">
<div class="empty">
${this.folders.length === 0
? (this.currentLibraryFilter !== null
? 'No pending folders in the selected library. Switch the library filter or scan to find untagged albums.'
: 'No pending folders. Untagged albums appear here after a library scan.')
: 'Pick a folder from the list on the left to review.'}
Pick a folder from the list on the left to review.
</div>
</div>
`;
@@ -1,6 +1,5 @@
import {
GetAlbumTracks,
GetAlbumTracksByLibrary,
GetFilePathsByAlbums,
} from '@go/library/library.js';
import { libraryStore } from '@store/library-store';
@@ -59,11 +58,7 @@ export class AlbumSelectionManager {
const libId =
libraryStore.getSelectedLibraryId();
return list(
libId !== null
? GetAlbumTracksByLibrary(albumId, libId)
: GetAlbumTracks(albumId),
);
return list(GetAlbumTracks(albumId, libId ?? 0));
}
/**
@@ -13,7 +13,6 @@ import type {
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import {
GetAlbumTracks,
GetAlbumTracksByLibrary,
} from '@go/library/library.js';
import * as library from '@go/library/models.js';
import { LibraryController } from '@store/controllers/library-controller';
@@ -935,12 +934,7 @@ export class CoverGrid
this.libraryCtrl.selectedLibraryId;
const tracks = await list(
libId !== null
? GetAlbumTracksByLibrary(
album.ID,
libId,
)
: GetAlbumTracks(album.ID),
GetAlbumTracks(album.ID, libId ?? 0),
);
if (this.expandedAlbumId === album.ID) {
@@ -1494,6 +1488,33 @@ export class CoverGrid
switch (action) {
case 'play':
// One track row is a position in the expanded album, so
// it queues that album from there - the same thing
// double-clicking the row does. Anything else (several
// rows, or an album card) is already an explicit choice
// of exactly what to play.
if (
this.contextMenuTarget.kind === 'track' &&
filePaths.length === 1
) {
const start = this.expandedTracks.findIndex(
(t) => t.FilePath === filePaths[0],
);
if (start >= 0) {
queueStore.setQueue(
this.expandedTracks.map(
(t) => t.FilePath,
),
start,
false,
source,
);
break;
}
}
queueStore.setQueue(filePaths, 0, true, source);
break;
case 'add-to-queue':
@@ -10,7 +10,6 @@ import {
import {
GetAlbumTracks,
GetAlbumCompleteness,
GetFilePathsByAlbums,
GetFilePathsByRecordingMBIDs,
} from '@go/library/library.js';
import * as library from '@go/library/models.js';
@@ -47,7 +46,10 @@ import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dict, dictByName } from '@utils/binding';
import { dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
import '@components/playlist-picker/playlist-picker.js';
/**
* The region the album header's own failures are rendered in.
@@ -199,6 +201,35 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
*/
@state() private localTracks: MBTrack[] = [];
/**
* The file behind each displayed track, resolved once when the
* tracklist settles rather than per click.
*
* This is the page's one answer to "do I own this". It used to be
* asked three different ways — a local album id, the backend's
* cross-reference, a cached MBID match, or *any* track flagged
* inLibrary — none of which is "there is a file", and then answered
* a fourth way at the moment the user clicked something. So a row
* could render owned, offer Play, and fail; on a real library 129
* catalog rows were in exactly that state.
*
* A path here means the track plays. Nothing else on this page is
* allowed to mean it.
*/
@state() private filePaths = new Map<string, string>();
/**
* Which MBIDs have been *asked* about, which is not the same as
* which resolved.
*
* A track the library does not have never lands in `filePaths`, so
* a guard keyed on the answer asks about it again on every render —
* an unbounded query loop for exactly the tracks the user does not
* own. This is not `@state`: it records work done, and changing it
* must not schedule a render.
*/
private askedFor = new Set<string>();
/** Open state of the "find this album" dialog. */
@state() private pickerOpen = false;
@@ -231,17 +262,20 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@query('#track-context-menu')
private contextMenuPopup!: WaPopup;
@query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup;
@query('track-details')
private trackDetailsDialog?: TrackDetails;
// -- ContextMenuHost interface --
// No playlist submenu on this page — every action here resolves a
// single track's file lazily by MBID, and the submenu exists for a
// caller that already has file paths in hand.
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
return undefined;
return this.playlistSubmenuPopup;
}
onContextMenuClose(): void {
@@ -763,6 +797,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
private hasScrolledToHighlight = false;
override updated() {
// Whatever is displayed needs its files known, and the
// tracklist can change from four directions - the local
// hydrate, the catalog browse, the cluster build, the version
// dropdown. Asking here covers all of them; resolveFilePaths
// returns immediately once every displayed MBID is in the map,
// so this settles after one pass.
void this.resolveFilePaths();
if (
(this.highlightTrackMBID || this.highlightTrackTitle) &&
!this.hasScrolledToHighlight &&
@@ -860,6 +902,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
this.versionEntries = [];
this.selectedVersionKey = '';
this.localTracks = [];
this.filePaths = new Map();
this.askedFor = new Set();
// Local-only album (no MBID) — populate entirely from library.
if (!mbid && this.localAlbumId) {
@@ -935,7 +979,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/**
* Hydrate album info and tracklist from the local library store.
* Uses GetAlbumTracks(album.ID) — same local DB call as cover-grid.
* Uses GetAlbumTracks(album.ID, libraryStore.libraryFilter()) — same local DB call as cover-grid.
* Returns true if a tracklist was populated from local data.
*/
/**
@@ -971,7 +1015,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// Fetch tracks via the same local DB call the cover-grid uses.
let tracks: Awaited<ReturnType<typeof GetAlbumTracks>>;
try {
tracks = await GetAlbumTracks(this.localAlbumId);
tracks = await GetAlbumTracks(this.localAlbumId, libraryStore.libraryFilter());
} catch {
this.loadingReleases = false;
return;
@@ -1007,6 +1051,10 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
private mapLocalTracks(
tracks: Awaited<ReturnType<typeof GetAlbumTracks>>,
): MBTrack[] {
// The rows carry the file paths; this is where they stop being
// thrown away.
this.rememberLocalPaths(tracks);
const mapped: MBTrack[] = (tracks ?? []).map((t) => ({
mbid: t.RecordingMBID || '',
title: t.TrackName,
@@ -1055,7 +1103,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
*/
private async loadLocalTracks(albumId: number): Promise<void> {
try {
const tracks = await GetAlbumTracks(albumId);
const tracks = await GetAlbumTracks(albumId, libraryStore.libraryFilter());
this.localTracks = this.mapLocalTracks(tracks);
} catch {
@@ -1065,7 +1113,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// A catalog fetch may have already built the version list
// without a "Your Library" entry to point at — rebuild now
// that there's local data to match against it.
if (this.releases.length > 0) this.buildClusters();
//
// Unconditionally, including when the catalog returned nothing:
// `buildVersionEntries` synthesises the library entry *from*
// these tracks, so the no-releases case is exactly the one that
// needs this. Guarded on `releases.length` before, an album the
// catalog could not answer for showed "No release data
// available" over a tracklist it was holding in memory.
this.buildClusters();
}
private async hydrateFromLibrary(mbid: string): Promise<boolean> {
@@ -1114,7 +1169,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// Fetch tracks via the same local DB call the cover-grid uses.
let tracks: Awaited<ReturnType<typeof GetAlbumTracks>>;
try {
tracks = await GetAlbumTracks(libraryAlbum.ID);
tracks = await GetAlbumTracks(libraryAlbum.ID, libraryStore.libraryFilter());
} catch {
return false;
}
@@ -1749,8 +1804,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// Guarded on `known` rather than on "fewer tracks than the
// cluster", which would swap in a catalog tracklist for
// every album whose tags simply never declared a total.
const incomplete = this.completeness?.known
&& !this.completeness.complete;
const answer = this.completenessAnswer();
const incomplete = answer?.known && !answer.complete;
if (incomplete) {
const fullRelease = this.findLibraryCluster(clusters);
@@ -1759,7 +1814,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return {
key: 'synthetic:library',
label: 'Your Library',
sublabel: `${this.completeness?.owned ?? 0} of ${this.completeness?.expected ?? 0} tracks · ${this.clusterLabel(fullRelease)}`,
sublabel: `${answer?.owned ?? 0} of ${answer?.expected ?? 0} tracks · ${this.clusterLabel(fullRelease)}`,
group: 'aggregate',
syntheticKind: 'library',
tracks: fullRelease.representative.tracks ?? [],
@@ -1948,75 +2003,183 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
* the album may be on the request list, which the button directly
* below this badge has reported as "Wanted" all along.
*/
/**
* How much of this album is here, from whichever side can say.
*
* The files answer first: `GetAlbumCompleteness` reads the "5/12"
* totals off the tags, which is exact and costs no network. A great
* deal of any library declares no total at all, and for those the
* *catalog* carries one — a per-release-group track count in
* `explore_index`, shipped in the artifact for the price of about
* two bytes a row.
*
* The numerator stays the local one either way: how many distinct
* track numbers are on disk. Only the denominator is borrowed, and
* only when the tags have none — a catalog total is a statement
* about the canonical release, and the files' own total, where they
* declare one, is a statement about the release the user actually
* has.
*
* Zero still means "the catalog does not say", so an album neither
* side can total stays `known: false` and wears no ring.
*/
private completenessAnswer(): library.AlbumCompleteness | null {
const local = this.completeness;
if (local?.known) return local;
const expected = this.releaseGroup?.totalTracks ?? 0;
if (expected <= 0 || !local) return local;
return {
...local,
expected,
known: true,
complete: local.owned >= expected,
};
}
/**
* What the badge beside the album title shows.
*
* `albumLibraryStatus()` answers "is any of this yours", which is
* the right question for a tick and the wrong one for a ring. The
* ring needs a denominator, and it only exists when the files
* declared one — so an owned album with untotalled tags keeps the
* plain tick rather than wearing an arc drawn from a guess.
* ring needs a denominator, and an album neither the files nor the
* catalog can total keeps the plain tick rather than wearing an arc
* drawn from a guess.
*/
private albumBadgeStatus(): LibraryStatus {
const owned = this.albumLibraryStatus();
if (owned !== 'in-library') return owned;
const c = this.completeness;
const c = this.completenessAnswer();
if (c?.known && !c.complete) return 'partial';
return 'in-library';
}
/**
* How a displayed track is identified in `filePaths`.
*
* A recording MBID where there is one, and disc/track/title where
* there is not — a library-only album's tracks are synthesised from
* the files' own tags and may carry no MBID at all, which is the
* case an MBID-keyed lookup silently misses.
*/
private static trackKey(t: MBTrack): string {
if (t.mbid) return t.mbid;
return `${t.discNumber || 1}:${t.position}:${t.title.toLowerCase()}`;
}
/** The file behind a displayed track, or '' if the user has none. */
private filePathFor(t: MBTrack): string {
return this.filePaths.get(ExploreAlbumDetails.trackKey(t)) ?? '';
}
/**
* Record the files behind the local album's own tracks.
*
* These cost nothing: `GetAlbumTracks` already returned the paths,
* and this is the one place they were being thrown away.
*/
private rememberLocalPaths(
rows: Awaited<ReturnType<typeof GetAlbumTracks>>,
): void {
const paths = new Map(this.filePaths);
for (const row of rows ?? []) {
if (!row.FilePath) continue;
const key = ExploreAlbumDetails.trackKey({
mbid: row.RecordingMBID || '',
title: row.TrackName,
position: row.TrackNumber || 0,
discNumber: row.DiscNumber || 1,
} as MBTrack);
paths.set(key, row.FilePath);
}
this.filePaths = paths;
}
/**
* Resolve the catalog tracklist's files in one query.
*
* Called when the displayed tracklist changes rather than when a
* user clicks: the answer decides what the rows look like and which
* menu items exist, so it has to be known before either is drawn.
*/
private async resolveFilePaths(): Promise<void> {
const tracks = this.currentVersion()?.tracks ?? [];
const wanted = tracks
.map((t) => t.mbid)
.filter((mbid) => mbid && !this.askedFor.has(mbid));
if (wanted.length === 0) return;
for (const mbid of wanted) this.askedFor.add(mbid);
try {
const byMBID = await dictByName(
GetFilePathsByRecordingMBIDs(wanted, libraryStore.libraryFilter()),
);
const paths = new Map(this.filePaths);
for (const [mbid, forMBID] of Object.entries(byMBID)) {
const first = forMBID?.[0];
if (first) paths.set(mbid, first);
}
this.filePaths = paths;
} catch (error) {
// A failure here means the page cannot say what is owned, so
// it says nothing rather than guessing: rows stay dimmed and
// the actions that need a file stay absent.
console.error('Could not resolve library files for this album:', error);
}
}
/**
* Whether any of this album is the user's.
*
* One question, asked once: does any displayed track have a file.
* It used to be four claims of decreasing confidence OR'd into a
* single tick — a local album id, the backend's cross-reference, a
* cached MBID match, and finally *any* track flagged `inLibrary` —
* none of which is "there is a file", which is why the badge could
* say yes about an album whose every action failed.
*
* When it is not owned the answer is not automatically "no": the
* album may be on the request list, which the button below the
* badge has reported as "Wanted" all along.
*/
private albumLibraryStatus(): LibraryStatus {
if (this.localAlbumId > 0) return 'in-library';
if (this.ownership().owned > 0) return 'in-library';
if (this.releaseGroup?.inLibrary) return 'in-library';
const mbid = this.releaseGroupMBID;
if (mbid) {
const cachedAlbums = libraryStore.cachedAlbums;
if (cachedAlbums) {
for (const a of cachedAlbums) {
if (a.MBID === mbid) return 'in-library';
}
}
}
const current = this.currentVersion();
if (current) {
for (const t of current.tracks) {
if (t.inLibrary) return 'in-library';
}
}
// None of the five ownership claims held, so the badge falls
// through to the one thing this page already knew and never
// said: whether the album is on the request list. The button
// below it has read "Wanted" all along.
return libraryStatusFor(false, this.releaseGroupMBID);
}
/**
* How much of the shown release the user actually has.
*
* The tick beside the title is a yes/no answer to "is any of this
* mine", and four of its five branches can be true when one track
* of forty matches. That is fine for a badge and useless for a
* button: "Play" that plays one track of a forty-track release is
* worse than no Play button, so the header asks this instead.
* Counted off the tracklist being displayed, by how many of its
* tracks resolved to a file. That is the only claim on this page
* that is not an inference: a path means the track plays.
*
* It is counted off the *tracklist being displayed*, which is the
* one thing on this page that is not an inference — each track's
* `inLibrary` is set by the backend from its recording MBID
* (`markReleasesInLibrary`), the same key the file paths are
* fetched by.
* It is what the header's buttons key off, because "Play" that
* plays one track of a forty-track release is worse than no Play
* button — and it is now also what the badge above them uses, so
* the two can no longer disagree.
*/
private ownership(): { owned: number; total: number } {
const tracks = this.currentVersion()?.tracks ?? [];
return {
owned: tracks.filter((t) => t.inLibrary).length,
owned: tracks.filter((t) => this.filePathFor(t) !== '').length,
total: tracks.length,
};
}
@@ -2064,6 +2227,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
${this.renderVersionSelector()}
${this.renderTracklist()}
</div>
<track-details></track-details>
`;
}
@@ -2130,8 +2294,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
<span class="album-title-text">${this.albumName}</span>
<library-status-indicator
status=${this.albumBadgeStatus()}
.owned=${this.completeness?.owned ?? 0}
.expected=${this.completeness?.expected ?? 0}
.owned=${this.completenessAnswer()?.owned ?? 0}
.expected=${this.completenessAnswer()?.expected ?? 0}
entity-type="album"
label=${this.albumName}
size="22"
@@ -2181,7 +2345,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
size="small"
appearance="filled"
data-testid="album-play"
@click=${() => void this.playOwned(false)}
@click=${() => this.playOwned(false)}
>
<wa-icon slot="start" name="play"></wa-icon>
${playLabel}
@@ -2190,7 +2354,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
size="small"
appearance="outlined"
data-testid="album-shuffle"
@click=${() => void this.playOwned(true)}
@click=${() => this.playOwned(true)}
>
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle album
@@ -2199,7 +2363,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
size="small"
appearance="outlined"
data-testid="album-queue"
@click=${() => void this.queueOwned()}
@click=${() => this.queueOwned()}
>
<wa-icon slot="start" name="list"></wa-icon>
Add to queue
@@ -2240,163 +2404,103 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return { type: 'album', id: this.localAlbumId, label: this.albumName };
}
private async ownedFilePaths(): Promise<string[]> {
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
// The local album id is the better key whenever there is one:
// it needs no MBIDs at all, and a library-only album has none —
// its tracks are synthesised from `GetAlbumTracks` with
// `mbid: RecordingMBID || ''`, so an untagged library resolves
// to an empty set and the Play button silently does nothing.
// That is exactly what the first version of this did.
if (this.localAlbumId > 0) {
const byAlbum = await dict(
GetFilePathsByAlbums([this.localAlbumId], libraryID),
);
return byAlbum[this.localAlbumId] ?? [];
}
// Catalog-only: the page knows what is owned as recording MBIDs
// and nothing else — which is how the backend decided each
// track's `inLibrary` in the first place.
const tracks = this.currentVersion()?.tracks ?? [];
const mbids = tracks
.filter((t) => t.inLibrary && t.mbid)
.map((t) => t.mbid);
if (mbids.length === 0) return [];
const byMBID = await dictByName(
GetFilePathsByRecordingMBIDs(mbids, libraryID),
);
// Walked in tracklist order rather than flattened, because the
// grouping is what lets the caller keep its own order. A
// recording with more than one file is a duplicate; play the
// first and leave the rest to the feature that exists for them.
/**
* The files behind the displayed tracklist, in its order.
*
* No query: the paths were resolved when the tracklist settled.
* This used to be two different lookups chosen by a branch - by
* local album id, or by recording MBID for a catalog-only album -
* and the second silently returned nothing for an untagged library,
* because those tracks carry no MBID at all.
*/
private ownedFilePaths(): string[] {
const paths: string[] = [];
for (const mbid of mbids) {
const first = byMBID[mbid]?.[0];
for (const track of this.currentVersion()?.tracks ?? []) {
const path = this.filePathFor(track);
if (first) paths.push(first);
// A recording with more than one file is a duplicate; the
// map holds the first and the rest are the duplicate
// feature's business.
if (path) paths.push(path);
}
return paths;
}
/** Play what the user owns of this release, optionally shuffled. */
private async playOwned(shuffle: boolean): Promise<void> {
try {
const paths = await this.ownedFilePaths();
private playOwned(shuffle: boolean): void {
const paths = this.ownedFilePaths();
if (paths.length === 0) {
notificationStore.inline(ExploreAlbumRegion, {
text: 'None of these tracks could be found in your library.',
});
// The button is only rendered when there is something to play,
// so an empty set here is not a state the user can reach.
if (paths.length === 0) return;
return;
}
// `shuffleStart` only picks a random first track when
// shuffle mode is *already* on — it does not turn it on —
// so the mode has to be set before the queue, not after.
if (shuffle && !queueStore.getState().shuffleMode) {
queueStore.toggleShuffle();
}
queueStore.setQueue(paths, 0, shuffle, this.queueSource());
} catch (error) {
console.error('Could not play album:', error);
notificationStore.inline(ExploreAlbumRegion, {
text: describeError(error, 'Could not play this album.'),
});
// `shuffleStart` only picks a random first track when shuffle
// mode is *already* on — it does not turn it on — so the mode
// has to be set before the queue, not after.
if (shuffle && !queueStore.getState().shuffleMode) {
queueStore.toggleShuffle();
}
queueStore.setQueue(paths, 0, shuffle, this.queueSource());
}
/** Append what the user owns of this release to the queue. */
private async queueOwned(): Promise<void> {
try {
const paths = await this.ownedFilePaths();
private queueOwned(): void {
const paths = this.ownedFilePaths();
if (paths.length === 0) {
notificationStore.inline(ExploreAlbumRegion, {
text: 'None of these tracks could be found in your library.',
});
if (paths.length === 0) return;
return;
}
queueStore.addTracksToQueue(paths);
} catch (error) {
console.error('Could not queue album:', error);
notificationStore.inline(ExploreAlbumRegion, {
text: describeError(
error,
'Could not add this album to the queue.',
),
});
}
queueStore.addTracksToQueue(paths);
}
/**
* File path for one owned track, resolved by recording MBID — the
* same key the backend used to mark it `inLibrary` in the first
* place. Unlike `ownedFilePaths()` this does not special-case
* `localAlbumId`: a single track's own MBID is enough, and every
* `MBTrack` carries one regardless of how the album itself was
* matched.
* Play an owned track *in the context of the release it is on*:
* the whole owned tracklist is queued and playback starts at that
* track. Activating a row is a position in an album, not a request
* to throw the album away - "Add to Queue" and "Play Next" are what
* a caller reaches for when it wants the one track.
*/
private async trackFilePath(track: MBTrack): Promise<string | null> {
if (!track.inLibrary || !track.mbid) return null;
private playTrack(track: MBTrack): void {
const path = this.filePathFor(track);
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byMBID = await dictByName(
GetFilePathsByRecordingMBIDs([track.mbid], libraryID),
);
// Every path into this is gated on the row having a file: the
// row is not activatable without one and the menu offers
// nothing that needs one. There is no "could not be found in
// your library" any more, because the page no longer offers an
// action it cannot perform.
if (!path) return;
return byMBID[track.mbid]?.[0] ?? null;
}
/** Play a single owned track now. A no-op for a track not in the library. */
private async playTrack(track: MBTrack): Promise<void> {
try {
const path = await this.trackFilePath(track);
if (!path) {
notificationStore.inline(ExploreAlbumRegion, {
text: 'This track could not be found in your library.',
});
return;
}
const paths = this.ownedFilePaths();
const start = paths.indexOf(path);
// `start` is only -1 if the row is not in the version currently
// displayed, which no gesture on this page can produce; playing
// the one track is the honest answer to it either way.
if (start < 0) {
queueStore.setQueue([path], 0, false, this.queueSource());
} catch (error) {
console.error('Could not play track:', error);
notificationStore.inline(ExploreAlbumRegion, {
text: describeError(error, 'Could not play this track.'),
});
return;
}
queueStore.setQueue(paths, start, false, this.queueSource());
}
private async queueTrackNext(track: MBTrack): Promise<void> {
const path = await this.trackFilePath(track);
private queueTrackNext(track: MBTrack): void {
const path = this.filePathFor(track);
if (path) queueStore.playNext(path);
}
private async addTrackToQueue(track: MBTrack): Promise<void> {
const path = await this.trackFilePath(track);
private addTrackToQueue(track: MBTrack): void {
const path = this.filePathFor(track);
if (path) queueStore.addToQueue(path);
}
private onTrackRowDblClick(track: MBTrack): void {
if (!track.inLibrary) return;
void this.playTrack(track);
this.playTrack(track);
}
private onTrackRowKeydown(e: KeyboardEvent, track: MBTrack): void {
@@ -2408,9 +2512,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return;
}
if ((e.key === 'Enter' || e.key === ' ') && track.inLibrary) {
if ((e.key === 'Enter' || e.key === ' ') && this.filePathFor(track)) {
e.preventDefault();
void this.playTrack(track);
this.playTrack(track);
}
}
@@ -2422,30 +2526,81 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
this.ctxMenu.openAt(e.clientX, e.clientY);
}
private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void {
private onContextMenuAction(
action: 'play' | 'add-to-queue' | 'play-next' | 'track-details',
): void {
const track = this.ctxMenuTrack;
this.ctxMenu.close();
if (!track || !track.inLibrary) return;
if (!track || !this.filePathFor(track)) return;
switch (action) {
case 'play':
void this.playTrack(track);
this.playTrack(track);
break;
case 'add-to-queue':
void this.addTrackToQueue(track);
this.addTrackToQueue(track);
break;
case 'play-next':
void this.queueTrackNext(track);
this.queueTrackNext(track);
break;
case 'track-details':
void this.openTrackDetails(track);
break;
}
}
/**
* Open the "Add to Playlist" submenu for the track the menu is on.
*
* No await and no guards: the file was resolved when the tracklist
* settled, so the submenu opens or the item was never rendered.
* This used to resolve on demand, which meant a hover could report
* a failure for a menu the user was passing through.
*/
private openPlaylistSubmenu(): void {
const track = this.ctxMenuTrack;
if (!track) return;
const path = this.filePathFor(track);
if (!path) return;
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu([path]);
}
/**
* The details dialog for an owned track.
*
* It needs the library's own `Track`, which this page never has —
* its rows are the catalog's — so the file path is the way in, and
* the shared opener turns it back into a track.
*/
private async openTrackDetails(track: MBTrack): Promise<void> {
const path = this.filePathFor(track);
if (!path) return;
const outcome = await showTrackDetailsForPath(
() => this.trackDetailsDialog,
path,
() => void this.openTrackDetails(track),
);
// The file exists but the library store does not know it: a
// rescan removed it since the page loaded, which is the one
// case the resolved map cannot rule out.
if (outcome === 'not-in-library') {
notificationStore.inline(ExploreAlbumRegion, {
text: 'This track is no longer in your library.',
});
}
}
/**
* Explore's tracks carry a recording MBID whether or not the user
* owns them — this is the one context-menu action that works on a
* track the library doesn't have, since it needs no file at all.
* track the library does not have, since it needs no file at all.
*/
private viewTrackOnMusicBrainz(): void {
const track = this.ctxMenuTrack;
@@ -2612,7 +2767,12 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
* to `unavailable`.
*/
private catalogScope(): CatalogScope {
if (!this.releaseGroupMBID) return 'library';
// A library-only album says nothing: the header names it, the
// badge says it is yours, and the tracklist is the files'
// own — there is nothing absent for a notice to warn about.
// The artist page keeps its 'library' state because there a
// missing catalog means missing *sections*.
if (!this.releaseGroupMBID) return 'catalog';
if (this.catalogReleasesLoaded) return 'catalog';
// A complete, MBID-matched album is not missing anything the
@@ -2919,20 +3079,27 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
</div>
`
: nothing}
${discTracks.map(
(track) => html`
${discTracks.map((track) => {
// A row is owned if a file is behind it.
// It used to be the backend's inLibrary
// flag, which was set from a metadata
// row and could be true for a track
// that could not be played.
const owned = this.filePathFor(track) !== '';
return html`
<div
class=${classMap({
'track-row': true,
owned: track.inLibrary,
unowned: !track.inLibrary,
owned,
unowned: !owned,
})}
data-track-mbid="${track.mbid}"
data-track-title="${track.title}"
tabindex="0"
role="button"
aria-disabled=${track.inLibrary ? 'false' : 'true'}
aria-label=${track.inLibrary
aria-disabled=${owned ? 'false' : 'true'}
aria-label=${owned
? `Play “${track.title}`
: `${track.title} — not in your library`}
@dblclick=${() => this.onTrackRowDblClick(track)}
@@ -2952,7 +3119,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
track.length,
)}</span
>
${track.inLibrary
${owned
? nothing
: html`
<library-status-indicator
@@ -2968,8 +3135,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
></library-status-indicator>
`}
</div>
`,
)}
`;
})}
`;
})}
</div>
@@ -2992,23 +3159,55 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
${this.ctxMenu.contextMenuOpen && track
? html`
<div class="context-menu-panel" role="menu" aria-label="Track actions">
${track.inLibrary
${this.filePathFor(track) !== ''
? html`
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('add-to-queue')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play-next')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => this.openPlaylistSubmenu()}
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
this.openPlaylistSubmenu();
}}
>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Playlist
<span class="submenu-arrow">&#9654;</span>
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('track-details')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="circle-info"></wa-icon>
Track Details
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item @click=${() => this.viewTrackOnMusicBrainz()}>
<wa-dropdown-item
@click=${() => this.viewTrackOnMusicBrainz()}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
@@ -3016,6 +3215,29 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
`
: nothing}
</wa-popup>
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
? html`
<div
@mouseenter=${() => this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
>
<playlist-picker
.filePaths=${this.ctxMenu.playlistFilePaths}
@playlist-action-complete=${this.ctxMenu.onPlaylistActionComplete}
@click=${(e: Event) => e.stopPropagation()}
></playlist-picker>
</div>
`
: nothing}
</wa-popup>
`;
}
}
@@ -56,6 +56,9 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dict, dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
import '@components/playlist-picker/playlist-picker.js';
/* ── Constants ── */
@@ -199,20 +202,33 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@query('#context-menu')
private contextMenuPopup!: WaPopup;
@query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup;
@query('track-details')
private trackDetailsDialog?: TrackDetails;
/**
* The open menu's file paths, resolved once per open — see the same
* field on the album page. Only a track menu ever has any: a
* release's tracks are a different question, and adding a whole
* album to a playlist from here is not what this item says.
*/
private ctxMenuPaths: Promise<string[]> | null = null;
// -- ContextMenuHost interface --
// No playlist submenu here, for the same reason as the album page:
// every action resolves one recording's file lazily by MBID.
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
return undefined;
return this.playlistSubmenuPopup;
}
onContextMenuClose(): void {
this.ctxMenuTarget = null;
this.ctxMenuPaths = null;
}
/** The open menu's track, or null when it is not a track menu. */
@@ -1313,10 +1329,13 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
}
}
// Discography: fetch albums by artist ID and seed thumbnails
// Discography: fetch the artist's albums and seed thumbnails
// directly from library cover art.
try {
const albums = await GetAlbumsByArtist(this.localArtistId);
const albums = await GetAlbumsByArtist(
this.artistName,
libraryStore.libraryFilter(),
);
const thumbUpdates = new Map(this.thumbnailURLs);
let thumbsChanged = false;
@@ -2231,7 +2250,9 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
);
}
private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void {
private onContextMenuAction(
action: 'play' | 'add-to-queue' | 'play-next' | 'track-details',
): void {
const track = this.ctxMenuTrack;
this.ctxMenu.close();
@@ -2248,6 +2269,88 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
case 'play-next':
void this.queueTrackNext(track);
break;
case 'track-details':
void this.openTrackDetails(track);
break;
}
}
/**
* Open the "Add to Playlist" submenu for the track the menu is on.
*
* The path is resolved on demand rather than at menu-open time —
* the release menus that share this panel never need one, and a
* right-click on a track is not a statement that a playlist is
* coming. A menu closed while the lookup was in flight must not
* sprout a submenu afterwards.
*/
private async openPlaylistSubmenu(explicit: boolean): Promise<void> {
const track = this.ctxMenuTrack;
if (!track || !this.isTrackOwned(track)) return;
this.ctxMenu.clearSubmenuCloseTimer();
this.ctxMenuPaths ??= this.trackFilePath(track).then((p) =>
p ? [p] : []);
let paths: string[] = [];
try {
paths = await this.ctxMenuPaths;
} catch (error) {
console.error('Could not resolve the tracks file:', error);
this.ctxMenuPaths = null;
}
if (!this.ctxMenu.contextMenuOpen) return;
if (paths.length === 0) {
// Only an explicit activation gets an answer. A hover is how
// a submenu is *reached*, including on the way to the item
// below it — reporting a failure from one would put an error
// on screen for a menu the user was only passing through,
// and closing the menu under the pointer is worse still.
if (!explicit) return;
this.ctxMenu.close();
notificationStore.inline(ExploreArtistRegion, {
text: 'This track could not be found in your library.',
});
return;
}
await this.ctxMenu.showPlaylistSubmenu(paths);
}
/**
* The details dialog for an owned top track.
*
* The dialog wants the library's `Track` and this page has the
* catalog's recording, so the route in is the same MBID → file path
* resolution the playback actions use.
*/
private async openTrackDetails(track: LBTopRecording): Promise<void> {
try {
const path = await this.trackFilePath(track);
const outcome = path
? await showTrackDetailsForPath(
() => this.trackDetailsDialog,
path,
() => void this.openTrackDetails(track),
)
: 'not-in-library';
if (outcome === 'not-in-library') {
notificationStore.inline(ExploreArtistRegion, {
text: 'This track could not be found in your library.',
});
}
} catch (error) {
console.error('Could not open track details:', error);
notificationStore.inline(ExploreArtistRegion, {
text: describeError(error, 'Could not open track details.'),
});
}
}
@@ -2458,6 +2561,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
testid="artist-action-message"
></inline-notice>
${this.renderContextMenu()}
<track-details></track-details>
`;
}
@@ -2529,6 +2633,29 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
`
: nothing}
</wa-popup>
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
? html`
<div
@mouseenter=${() => this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
>
<playlist-picker
.filePaths=${this.ctxMenu.playlistFilePaths}
@playlist-action-complete=${this.ctxMenu.onPlaylistActionComplete}
@click=${(e: Event) => e.stopPropagation()}
></playlist-picker>
</div>
`
: nothing}
</wa-popup>
`;
}
@@ -2536,21 +2663,53 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
return html`
${this.isTrackOwned(track)
? html`
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('add-to-queue')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play-next')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => void this.openPlaylistSubmenu(false)}
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.openPlaylistSubmenu(true);
}}
>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Playlist
<span class="submenu-arrow">&#9654;</span>
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('track-details')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="circle-info"></wa-icon>
Track Details
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item @click=${() => this.viewTrackOnMusicBrainz()}>
<wa-dropdown-item
@click=${() => this.viewTrackOnMusicBrainz()}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
@@ -7,7 +7,6 @@ import {
import * as library from '@go/library/models.js';
import {
GetTracksByGenre,
GetTracksByGenreByLibrary,
} from '@go/library/library.js';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
@@ -209,12 +208,7 @@ export class GenreDetails extends LitElement {
libraryStore.getSelectedLibraryId();
this.tracks = await list(
libId !== null
? GetTracksByGenreByLibrary(
this.genreName,
libId,
)
: GetTracksByGenre(this.genreName),
GetTracksByGenre(this.genreName, libId ?? 0),
);
} catch (error) {
console.error('Error loading genre tracks:', error);
@@ -381,7 +381,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
private async playAlbum(album: library.Album): Promise<void> {
try {
const tracks = await GetAlbumTracks(album.ID);
const tracks = await GetAlbumTracks(album.ID, libraryStore.libraryFilter());
const paths = (tracks ?? []).map((t) => t.FilePath).filter(Boolean);
if (paths.length === 0) return;
@@ -6,6 +6,7 @@ import {
} from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../notifications/inline-notice';
import {
FindPhantomMatches,
@@ -18,9 +19,16 @@ import type * as playlist from '@go/playlist/models.js';
import { formatMilliseconds } from '@utils/time';
import { nameDialogsIn } from '@utils/name-dialog';
import { list } from '@utils/binding';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import { srOnly } from '../../styles/sr-only.css';
const SEARCH_DEBOUNCE_MS = 400;
/** Failures here render inside the dialog: it is modal, so an
* app-level notification would sit behind it. */
export const PhantomResolverRegion = 'phantom-resolver';
/**
* A modal dialog for resolving phantom (unmatched) tracks
* in imported playlists.
@@ -129,6 +137,17 @@ export class PhantomResolver extends LitElement {
'Failed to find phantom matches:',
err,
);
notificationStore.inline(
PhantomResolverRegion,
{
text: describeError(
err,
'These tracks could not be matched against the library.',
),
key: 'phantom-find',
detail: String(err),
},
);
} finally {
this.loading = false;
}
@@ -156,6 +175,17 @@ export class PhantomResolver extends LitElement {
err,
);
this.candidates = [];
notificationStore.inline(
PhantomResolverRegion,
{
text: describeError(
err,
'No candidates could be looked up for that track.',
),
key: 'phantom-candidates',
detail: String(err),
},
);
} finally {
this.candidatesLoading = false;
}
@@ -180,6 +210,17 @@ export class PhantomResolver extends LitElement {
err,
);
this.searchResults = [];
notificationStore.inline(
PhantomResolverRegion,
{
text: describeError(
err,
'The library could not be searched.',
),
key: 'phantom-search',
detail: String(err),
},
);
} finally {
this.searching = false;
}
@@ -194,11 +235,84 @@ export class PhantomResolver extends LitElement {
void this.loadCandidatesForSelected();
}
private handleCandidateDblClick(
/**
* The unmatched list is a single-select listbox and selection
* follows focus: choosing a track is what fills the panel beside
* it, so there is nothing to "activate" separately.
*/
private handlePhantomKeydown(
e: KeyboardEvent,
path: string,
): void {
const items = this.unmatched;
const current = items.indexOf(path);
let next = current;
switch (e.key) {
case 'ArrowDown':
next = Math.min(current + 1, items.length - 1);
break;
case 'ArrowUp':
next = Math.max(current - 1, 0);
break;
case 'Home':
next = 0;
break;
case 'End':
next = items.length - 1;
break;
case 'Enter':
case ' ':
e.preventDefault();
this.handlePhantomClick(path);
return;
default:
return;
}
e.preventDefault();
const target = items[next];
if (target === undefined || next === current) return;
this.handlePhantomClick(target);
void this.focusPhantomItem(next);
}
/** The row is re-rendered with a new tabindex, so focus is taken
* after that update rather than on the element that had it. */
private async focusPhantomItem(index: number): Promise<void> {
await this.updateComplete;
const items =
this.shadowRoot?.querySelectorAll<HTMLElement>(
'.phantom-item',
);
items?.[index]?.focus();
}
private chooseCandidate(
candidate: playlist.CandidateTrack,
): void {
if (!this.selectedPhantom) return;
if (this.claimedPaths.has(candidate.FilePath)) {
notificationStore.inline(
PhantomResolverRegion,
{
text:
'That track is already standing in for another ' +
'unmatched track.',
key: 'phantom-claimed',
},
);
return;
}
this.confirmedMatches.set(
this.selectedPhantom,
candidate.FilePath,
@@ -308,6 +422,17 @@ export class PhantomResolver extends LitElement {
'Failed to remove phantom tracks:',
err,
);
notificationStore.inline(
PhantomResolverRegion,
{
text: describeError(
err,
'Those tracks could not be removed from the playlist.',
),
key: 'phantom-remove',
detail: String(err),
},
);
}
};
@@ -339,6 +464,20 @@ export class PhantomResolver extends LitElement {
'Failed to resolve phantom tracks:',
err,
);
// The dialog stays open on this path, so the message has to
// be in it: nothing else would tell the user why Apply did
// nothing.
notificationStore.inline(
PhantomResolverRegion,
{
text: describeError(
err,
'Those matches could not be applied to the playlist.',
),
key: 'phantom-apply',
detail: String(err),
},
);
return;
}
@@ -376,6 +515,74 @@ export class PhantomResolver extends LitElement {
).length;
}
/**
* Library files already standing in for some *other* phantom track.
* One file cannot resolve two of them — it would be added to the
* playlist twice — which is the rule `FindPhantomMatches` applies to
* its own auto-matches and the backend now enforces on apply.
*/
private get claimedPaths(): Set<string> {
const claimed = new Set<string>();
for (const m of this.effectiveAutoMatched) {
claimed.add(m.Candidate.FilePath);
}
for (const [phantom, resolved] of this.confirmedMatches) {
if (phantom !== this.selectedPhantom) claimed.add(resolved);
}
return claimed;
}
/**
* Search results the candidate list is not already showing.
*
* The two lists are rendered one under the other in the same panel,
* and a search for the obvious title returns exactly what scoring
* already found — so without this the same track appears twice, once
* with its score and once without.
*/
/** What the live region says: this dialog's work is all async and
* none of it is announced by the lists changing under it. */
private get liveStatus(): string {
if (this.loading) return 'Searching for matches.';
if (this.candidatesLoading) return 'Loading candidates.';
if (this.searching) return 'Searching the library.';
const extra = this.extraSearchResults.length;
if (this.searchQuery.trim() && this.searchResults.length > 0) {
return extra === 0
? 'Every match for that search is already listed.'
: `${extra} further ${
extra === 1 ? 'result' : 'results'
} from the library.`;
}
if (!this.selectedPhantom) return '';
return `${this.candidates.length} ${
this.candidates.length === 1 ? 'candidate' : 'candidates'
} for the selected track.`;
}
private get extraSearchResults(): playlist.CandidateTrack[] {
if (this.searchResults.length === 0) return [];
const shown = new Set(
this.candidates.map((c) => c.FilePath),
);
return this.searchResults.filter((c) => {
if (shown.has(c.FilePath)) return false;
shown.add(c.FilePath);
return true;
});
}
// ─── Formatting helpers ─────────────────────────
private formatDuration(ms: string): string {
@@ -395,11 +602,31 @@ export class PhantomResolver extends LitElement {
// ─── Rendering ──────────────────────────────────
static override styles = [
srOnly,
css`
wa-dialog {
--width: 860px;
}
/* The disclosure is a <button> now; it keeps the header's
own look rather than the UA's. */
button.auto-match-header {
width: 100%;
border: none;
font: inherit;
color: inherit;
text-align: left;
}
/* A file already standing in for another unmatched track:
shown, so the user can see where it went, but not
selectable. Dimming is a colour, so aria-disabled carries
the same fact to anyone not seeing it. */
.candidate-item.claimed {
opacity: 0.45;
cursor: not-allowed;
}
wa-dialog::part(dialog) {
background: var(
--yj-bg-surface,
@@ -950,6 +1177,15 @@ export class PhantomResolver extends LitElement {
private renderContent() {
return html`
<inline-notice
region=${PhantomResolverRegion}
testid="phantom-resolver-message"
></inline-notice>
<!-- Rendered empty and always present: a live region added
with its text already in it is not announced. -->
<div class="sr-only" role="status" aria-live="polite">
${this.liveStatus}
</div>
${this.renderAutoMatchSection()}
${this.unmatched.length > 0 ||
this.confirmedMatches.size > 0
@@ -964,9 +1200,17 @@ export class PhantomResolver extends LitElement {
if (matches.length === 0) return nothing;
// A disclosure is a button and says what it controls, or the
// review list behind it cannot be reached from the keyboard —
// the same fix `config-section` carries.
return html`
<div
<button
type="button"
class="auto-match-header"
aria-expanded=${this.autoMatchExpanded
? 'true'
: 'false'}
aria-controls="auto-match-list"
@click=${() => {
this.autoMatchExpanded =
!this.autoMatchExpanded;
@@ -991,11 +1235,12 @@ export class PhantomResolver extends LitElement {
>
(click to review)
</span>
</div>
${this.autoMatchExpanded
? html`<div
class="auto-match-list"
>
</button>
<div
id="auto-match-list"
class="auto-match-list"
?hidden=${!this.autoMatchExpanded}
>
${matches.map(
(m) => html`
<div
@@ -1062,8 +1307,7 @@ export class PhantomResolver extends LitElement {
</div>
`,
)}
</div>`
: nothing}
</div>
`;
}
@@ -1075,9 +1319,13 @@ export class PhantomResolver extends LitElement {
Unmatched
(${this.unresolvedCount})
</div>
<div class="panel-body">
<div
class="panel-body"
role="listbox"
aria-label="Unmatched tracks"
>
${this.unmatched.map(
(path) => {
(path, index) => {
const isSelected =
this
.selectedPhantom ===
@@ -1105,10 +1353,26 @@ export class PhantomResolver extends LitElement {
: ''} ${isMatched
? 'matched'
: ''}"
role="option"
tabindex=${isSelected ||
(!this.selectedPhantom &&
index === 0)
? '0'
: '-1'}
aria-selected=${isSelected
? 'true'
: 'false'}
@click=${() =>
this.handlePhantomClick(
path,
)}
@keydown=${(
e: KeyboardEvent,
) =>
this.handlePhantomKeydown(
e,
path,
)}
title=${path}
>
${isMatched
@@ -1188,7 +1452,7 @@ export class PhantomResolver extends LitElement {
found. Try
searching below.
</div>`}
${this.searchResults.length > 0
${this.extraSearchResults.length > 0
? html`
<div
class="dbl-click-hint"
@@ -1197,7 +1461,7 @@ export class PhantomResolver extends LitElement {
Library search
results
</div>
${this.searchResults.map(
${this.extraSearchResults.map(
(c) =>
this.renderCandidateItem(
c,
@@ -1205,6 +1469,13 @@ export class PhantomResolver extends LitElement {
)}
`
: nothing}
${this.searchResults.length > 0 &&
this.extraSearchResults.length === 0
? html`<div class="empty-message">
Every match for that search is already
listed above.
</div>`
: nothing}
${this.searching
? html`<div
class="empty-message"
@@ -1240,14 +1511,26 @@ export class PhantomResolver extends LitElement {
const meta = [c.Artist, c.Album]
.filter(Boolean)
.join(' \u2014 ');
const claimed = this.claimedPaths.has(c.FilePath);
// A double-click is the pointer shortcut, not the only way in:
// the row is a button, so Enter and Space match it too.
return html`
<div
class="candidate-item"
@dblclick=${() =>
this.handleCandidateDblClick(
c,
)}
class="candidate-item ${claimed ? 'claimed' : ''}"
role="button"
tabindex="0"
aria-disabled=${claimed ? 'true' : 'false'}
aria-label=${`Match with ${title}${
meta ? `, ${meta}` : ''
}${claimed ? ' (already used)' : ''}`}
@dblclick=${() => this.chooseCandidate(c)}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
this.chooseCandidate(c);
}}
title=${c.FilePath}
>
<div class="candidate-info">
@@ -450,7 +450,19 @@ export class PlaylistDetails
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
// One row is a position in the playlist, so it queues
// the playlist from there - the same thing
// double-clicking the row does. Several rows are an
// explicit choice of *those* tracks and become the
// queue on their own.
if (filePaths.length === 1) {
this.handleTrackDblClick(
this.selection.getSelectedIndices()[0]!,
);
} else {
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
}
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
@@ -919,7 +919,19 @@ export class SmartPlaylistDetails
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
// One row is a position in the playlist, so it queues
// the playlist from there - the same thing
// double-clicking the row does. Several rows are an
// explicit choice of *those* tracks and become the
// queue on their own.
if (filePaths.length === 1) {
this.handleTrackDblClick(
this.selection.getSelectedIndices()[0]!,
);
} else {
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
}
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
@@ -1242,12 +1242,32 @@ export class TrackList
* has existed in the defaults and in Settings since it was written
* and has never had anything on the other end of it. */
private handleShortcutPlay = (): void => {
const filePaths = this.selection.getSelectedKeysOrdered();
this.playSelection(this.selection.getSelectedKeysOrdered());
};
/**
* "Play" means the same thing from the menu and from Enter, and it
* asks how much the user selected. One row is a position in the
* list - it queues the list from there, exactly as double-clicking
* does. Several rows are an explicit choice of *those* tracks, so
* they become the queue on their own (and `shuffleStart` applies,
* since no one row was named as the place to start).
*/
private playSelection(filePaths: string[]): void {
if (filePaths.length === 0) return;
if (filePaths.length === 1) {
const index = this.displayIndexOf(filePaths[0]!);
if (index >= 0) {
this.playFromRow(index);
return;
}
}
queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource);
};
}
override willUpdate(
changed: Map<PropertyKey, unknown>,
@@ -1544,7 +1564,7 @@ export class TrackList
private onDelegatedDblClick = (e: MouseEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (hit) this.onTrackRowDblClick(hit.track);
if (hit) this.onTrackRowDblClick(hit.track, hit.index);
};
private onDelegatedContextMenu = (e: MouseEvent) => {
@@ -1574,9 +1594,45 @@ export class TrackList
this.selection.handleItemClick(e, track.FilePath, index);
}
private onTrackRowDblClick(track: library.Track) {
private onTrackRowDblClick(_track: library.Track, index: number) {
this.selection.clear();
queueStore.setQueue([track.FilePath], 0, false, this.effectiveQueueSource);
this.playFromRow(index);
}
/**
* Activating one row plays the list that row is in, from that row -
* the library, the artist or the genre the user is looking at, not
* a queue of one. The paths come from `cachedSortedTracks`, so it
* is the list as *displayed*: whatever the current sort, search and
* library filter have made of it, which is the only order the user
* can see and therefore the only one they can mean.
*/
private playFromRow(index: number) {
const filePaths = this.cachedSortedTracks.map(
(t) => t.FilePath,
);
if (filePaths.length === 0 || index < 0) return;
queueStore.setQueue(
filePaths,
index,
false,
this.effectiveQueueSource,
);
}
/**
* Where a "play this" command lands in the displayed list, or -1.
*
* Selection keys are file paths, which survive the re-sorts and
* refetches an index does not - so the index is looked up at the
* moment it is used rather than remembered.
*/
private displayIndexOf(filePath: string): number {
return this.cachedSortedTracks.findIndex(
(t) => t.FilePath === filePath,
);
}
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
@@ -1648,7 +1704,7 @@ export class TrackList
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource);
this.playSelection(filePaths);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);