feat(library): remove a track from the library without deleting the file

RemoveFromLibrary deletes the audio_files rows the way the scan's own
orphan cleanup does and records each path in excluded_paths. The
exclusion is not an enhancement: without it the next scan finds the
file, sees no row and imports it again, so the button undoes itself.

The soft scan compares files on disk against rows in the database, so
surveyAudioFiles and countAudioFiles both take the exclusion set —
otherwise an excluded path makes the two disagree forever and queues a
full scan on every launch. Deleting a row cascades to queue_tracks, so
the removal calls the same CompactQueue hook RemoveLibrary does.

Also lands the requested badge: library-status-indicator is a button
again where it can act, utils/library-status.ts states once what owning
and wanting mean, and the long-declared queued state finally has a
producer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
2026-08-14 13:12:01 -04:00
co-authored by Claude Opus 5
parent dcc40b1781
commit dc890d1fcc
48 changed files with 3450 additions and 103 deletions
@@ -26,6 +26,7 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor } from '@utils/library-status';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
@@ -623,6 +624,36 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
color: var(--yj-text-secondary, #b3b3b3);
font-weight: 400;
}
/* The request control is only offered where there is
* something to request, and only when the row is being
* attended to — a column of plus signs down a mostly-owned
* album is the clutter the green ticks were.
*
* Hidden with opacity, never display:none or visibility,
* so it keeps its place in the layout (rows do not reflow
* as the pointer moves) and stays in the tab order and the
* accessibility tree. focus-within is what makes it
* reachable without a mouse: tabbing to the button reveals
* it, and the row's own focus reveals it before you get
* there. */
.track-row .track-request {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s ease;
}
.track-row:hover .track-request,
.track-row:focus-within .track-request,
.track-row .track-request:focus-visible {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.track-row .track-request {
transition: none;
}
}
`,
];
@@ -1902,7 +1933,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
* - cachedAlbums has MBID match → owned
* - any selected version has → owned (covers local-only albums
* a track marked inLibrary where releaseGroup may be null)
* - else → not owned
* - else → whatever the request list says
*
* Four different claims of decreasing confidence, OR'd together and
* reported as one tick — the last of which fires when a *single*
@@ -1911,8 +1942,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
* actions key off; this stays as it was, because the indicator's
* job is "is any of this yours" and that is what it answers.
*
* No queued state for now — that's reserved for future
* download-client integration.
* When none of them hold the answer is not automatically "no":
* the album may be on the request list, which the button directly
* below this badge has reported as "Wanted" all along.
*/
/**
* What the badge beside the album title shows.
@@ -1934,7 +1966,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return 'in-library';
}
private albumLibraryStatus(): 'in-library' | 'not-in-library' {
private albumLibraryStatus(): LibraryStatus {
if (this.localAlbumId > 0) return 'in-library';
if (this.releaseGroup?.inLibrary) return 'in-library';
@@ -1956,7 +1988,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
}
}
return 'not-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);
}
/**
@@ -2465,9 +2501,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
appearance=${this.isRequested ? 'filled' : 'outlined'}
@click=${() => void this.toggleRequested(request?.id)}
>
<!-- The requested state used to ask for bookmark-check,
which is a Font Awesome *Pro* name: never bundled,
so this button has rendered the missing-icon
fallback in that state ever since. Outline and solid
of the same Free glyph carry the toggle instead. -->
<wa-icon
slot="start"
name=${this.isRequested ? 'bookmark-check' : 'bookmark'}
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
></wa-icon>
${this.isRequested ? 'Wanted' : 'Want this'}
</wa-button>
@@ -2906,6 +2947,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
track.length,
)}</span
>
${track.inLibrary
? nothing
: html`
<library-status-indicator
class="track-request"
status=${libraryStatusFor(
false,
track.mbid,
)}
entity-type="track"
label=${track.title}
request-mbid=${track.mbid}
request-artist=${this.artistName}
></library-status-indicator>
`}
</div>
`,
)}
@@ -39,6 +39,7 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor } from '@utils/library-status';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import { queueStore } from '../../store/queue-store';
@@ -2471,9 +2472,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
${formatListenCount(t.totalListenCount)} plays
</span>
<library-status-indicator
status=${t.inLibrary || t.localId ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)}
entity-type="track"
label=${t.trackName}
request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
></library-status-indicator>
</div>
`,
@@ -2583,9 +2586,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
</div>
<library-status-indicator
status=${rg.inLibrary || rg.localId ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)}
entity-type="album"
label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="18"
></library-status-indicator>
</div>
@@ -2678,9 +2683,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
const artURL = this.thumbnailURLs.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate);
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
const status: 'in-library' | 'not-in-library' = inLibrary
? 'in-library'
: 'not-in-library';
const status = libraryStatusFor(inLibrary, rg.mbid);
return html`
<div
@@ -2717,6 +2720,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
status=${status}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
></library-status-indicator>
</div>
</div>
@@ -1,4 +1,6 @@
import { avatarBackground } from '@utils/avatar-color';
import { libraryStatusFor } from '@utils/library-status';
import { downloadStore } from '@store/download-store';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
@@ -786,6 +788,19 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
this.cancelIndexStatus = EventsOn(Events.IndexStatusChanged, () => {
if (this.shelves?.state !== 'ready') void this.loadShelves();
});
// The badges on every result card say whether something is
// already requested, which a background reconcile pass changes
// without this page doing anything. Registered `whileActive`
// rather than on connect: this view is cached and never
// unmounts, so a connect-time subscription would run for the
// life of the session from pages it is not on.
//
// `init()` is four fetches, and it happens on arrival for the
// same reason `loadShelves()` does — a user who never opens
// Explore should not pay for it.
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
void downloadStore.init().then(() => this.requestUpdate());
}
/** A debounced search that lands after the user has left the page is
@@ -1816,6 +1831,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
<wa-icon class="search-icon" name="magnifying-glass"></wa-icon>
<input
type="text"
aria-label=${this.searchMode === 'lyrics'
? 'Search the catalog by a lyric'
: 'Search the catalog'}
placeholder=${placeholder}
.value=${this.searchQuery}
@input=${this.handleInput}
@@ -2146,9 +2164,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
${year ? html`<span>${year}</span>` : nothing}
</div>
<library-status-indicator
status=${this.libraryMBIDs.has(rg.mbid) || rg.inLibrary ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
></library-status-indicator>
</div>
</div>
@@ -2207,9 +2227,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
: nothing}
</div>
<library-status-indicator
status=${this.libraryMBIDs.has(r.mbid) || r.inLibrary ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)}
entity-type="track"
label=${r.title}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
></library-status-indicator>
</div>
`,
@@ -1,6 +1,9 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { toggleRequest } from '@utils/library-status';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
/**
* Library status for an entity (artist, album, or track).
@@ -25,18 +28,23 @@ export type LibraryStatus =
* Tri-state library status indicator: a small circular badge embedded
* in track rows, album cards, and artist cards.
*
* **It is a badge, not a control.** It was a `<button>` whose click
* handler was a `stopPropagation()` and a comment saying to wire up
* the download client later — so an Explore results page offered 20
* keyboard stops (of 66) that promised an action and performed none,
* and every one of them announced itself as a button. A control that
* cannot act is worse than no control: it costs the keyboard user the
* tab stop *and* the expectation.
* **It is a button only where it can act, and a badge everywhere
* else.** It used to be a `<button>` whose click handler was a
* `stopPropagation()` and a comment saying to wire up the download
* client later, so an Explore results page offered 20 keyboard stops
* (of 66) that promised an action and performed none. 007 made it
* `role="img"` for that reason and wrote down what would change the
* answer: a `<button>` again *with* a handler, never a handler bolted
* onto something already shaped like one.
*
* So it is `role="img"` with a label, until there is something to
* click. When the download-client integration lands, the right change
* is to make it a `<button>` again *with a handler* — not to add the
* handler to something already shaped like a button.
* A call site opts in by passing `request-mbid`. Where it does, this
* is a `<button>` that toggles a durable **request** — and the copy
* says so, because clicking still adds nothing to the library. Where
* it does not (`explore-album-details`'s header, which has "Want this"
* in words directly below it) it stays exactly what it was.
*
* An `in-library` badge is never a button under either: there is
* nothing left to ask for.
*
* Colours and glyphs:
* - in-library → green circle, check mark
@@ -89,6 +97,38 @@ export class LibraryStatusIndicator extends LitElement {
@property({ type: Number })
expected = 0;
/**
* MBID to request when this is clicked. Supplying it is what makes
* this a control; omitting it leaves a badge. Only `album` and
* `track` are requestable — see `utils/library-status.ts`.
*/
@property({ type: String, attribute: 'request-mbid' })
requestMbid = '';
/** Display-cache artist for the request list. Matching is by MBID. */
@property({ type: String, attribute: 'request-artist' })
requestArtist = '';
@state()
private busy = false;
/**
* True when this can act: a call site opted in, and there is
* something left to ask for.
*
* `partial` is deliberately actionable — an album you hold nine of
* twelve tracks of has three left to request, which is exactly the
* case worth asking about. Only `in-library` is complete enough to
* have nothing to ask for.
*/
private get actionable(): boolean {
return (
this.requestMbid !== '' &&
this.status !== 'in-library' &&
this.entityType !== 'artist'
);
}
static override styles = css`
:host {
display: inline-flex;
@@ -148,7 +188,8 @@ export class LibraryStatusIndicator extends LitElement {
/* A <button> gets box-sizing: border-box from the UA
* stylesheet and a <span> does not, so dropping the button
* grew the badge by its 1px border on each side — 36px to
* 38px, caught by the stored screenshot. */
* 38px, caught by the stored screenshot. Set explicitly so
* the two branches of render() are the same size. */
box-sizing: border-box;
width: var(--indicator-size);
height: var(--indicator-size);
@@ -168,6 +209,29 @@ export class LibraryStatusIndicator extends LitElement {
wa-icon {
font-size: calc(var(--indicator-size) * 0.55);
line-height: 1;
pointer-events: none;
}
button.badge {
cursor: pointer;
font: inherit;
}
button.badge:hover:not(:disabled) {
filter: brightness(1.25);
}
button.badge:disabled {
cursor: default;
opacity: 0.6;
}
/* The card underneath draws its own focus ring, and this sits
* inside it — so the badge needs one of its own or a keyboard
* user cannot tell which of the two has focus. */
button.badge:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
/* Prevent the button from intercepting drag gestures on album
@@ -204,6 +268,17 @@ export class LibraryStatusIndicator extends LitElement {
: 'track';
const name = this.label ? ` "${this.label}"` : '';
// A control is named after what activating it does; a badge is
// named after what it is. Both are still deliberately about the
// *request list* rather than the library — clicking this adds a
// row to one and nothing to the other, and "Add … to library"
// was the old button's promise written into the copy.
if (this.actionable) {
return this.status === 'queued'
? `Cancel the request for ${kind}${name}`
: `Want ${kind}${name}`;
}
switch (this.status) {
case 'in-library':
return `${capitalize(kind)}${name} is in your library`;
@@ -214,12 +289,64 @@ export class LibraryStatusIndicator extends LitElement {
case 'queued':
return `${capitalize(kind)}${name} is queued for download`;
default:
// Not "Add … to library": nothing here adds anything.
// The old copy was the button's promise written out.
return `${capitalize(kind)}${name} is not in your library`;
}
}
/**
* Toggle the request.
*
* The click is swallowed, which it was before too — but for the
* opposite reason. 007 removed a `stopPropagation()` that guarded
* nothing, on the rule that with no action of its own the badge is
* part of its card and a click on it should mean what the card
* means. Now it has one, so it does not.
*/
private async onActivate(event: Event) {
event.stopPropagation();
event.preventDefault();
if (this.busy || !this.actionable) return;
this.busy = true;
try {
await toggleRequest({
mbid: this.requestMbid,
entity: this.entityType === 'album' ? 'album' : 'track',
title: this.label,
artist: this.requestArtist,
});
} catch (err) {
console.error('could not update the request list', err);
// Transient: the badge visibly stayed where it was, so
// there is nothing for the user to do about it that they
// are not already doing.
notificationStore.transient({
text: describeError(err, 'That request could not be updated.'),
tone: 'error',
});
} finally {
this.busy = false;
}
}
/**
* Keep Enter and Space from reaching the card underneath.
*
* A `<button>` fires `click` on both by itself, so this only has to
* stop the keydown propagating — every card holding one of these is
* a `role="button"` or `role="option"` with its own Enter/Space
* handler, and without this a keyboard activation would both file
* the request and open the page.
*/
private onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation();
}
}
override render() {
// Sync the host CSS variable with the configured size.
if (this.size && this.size !== 20) {
@@ -228,13 +355,35 @@ export class LibraryStatusIndicator extends LitElement {
const title = this.tooltip();
// The ring stands in for the icon wherever the icon would go —
// including inside the button, because a partly-held album is
// actionable (it has tracks left to request) and must still
// show how much of it is here.
const glyph = this.status === 'partial'
? this.renderRing()
: this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing;
if (this.actionable) {
return html`
<button
class="badge"
type="button"
title=${title}
aria-label=${title}
?disabled=${this.busy}
@click=${this.onActivate}
@keydown=${this.onKeydown}
>
${glyph}
</button>
`;
}
return html`
<span class="badge" role="img" title=${title} aria-label=${title}>
${this.status === 'partial'
? this.renderRing()
: this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing}
${glyph}
</span>
`;
}
@@ -10,6 +10,8 @@ import {
import '../library-status-indicator/library-status-indicator.js';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
import { libraryStatusFor } from '../../utils/library-status';
import { downloadStore } from '../../store/download-store';
/** Format milliseconds as m:ss. */
function formatDuration(ms: number | undefined): string {
@@ -50,6 +52,28 @@ export class TopResultsRow extends LitElement {
// Per-card state: cover images.
private images = new Map<string, string>();
private unsubRequests?: () => void;
/**
* The badges here say whether something is already requested, and
* this row will not hear about a change from its host: `explore-view`
* re-rendering sets the same `results` array back, so Lit stops at
* the property and never updates this element. One subscription for
* the row, not one per card.
*/
override connectedCallback(): void {
super.connectedCallback();
this.unsubRequests = downloadStore.subscribe(() =>
this.requestUpdate(),
);
}
override disconnectedCallback(): void {
this.unsubRequests?.();
this.unsubRequests = undefined;
super.disconnectedCallback();
}
static override styles = [
designTokens,
exploreLinkStyles,
@@ -255,7 +279,10 @@ export class TopResultsRow extends LitElement {
? r.year || ''
: formatDuration(r.length) || '';
const status: LibraryStatus = r.inLibrary ? 'in-library' : 'not-in-library';
const status: LibraryStatus = libraryStatusFor(
Boolean(r.inLibrary),
r.mbid,
);
const entityType: 'artist' | 'album' | 'track' =
r.entityType === 'artist'
? 'artist'
@@ -314,6 +341,8 @@ export class TopResultsRow extends LitElement {
status=${status}
entity-type=${entityType}
label=${r.name}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
size="22"
></library-status-indicator>`}
</div>
@@ -63,6 +63,9 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { describeError } from '@utils/describe-error';
import { notificationStore } from '@store/notification-store';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import { RemoveFromLibrary } from '@go/library/Library';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import '@components/playlist-picker/playlist-picker.js';
@@ -1212,8 +1215,29 @@ export class TrackList
'shortcut:tracklist-play',
this.handleShortcutPlay,
);
this.listenWhileActive(
document,
'shortcut:tracklist-delete',
this.handleShortcutDelete,
);
}
/**
* Delete opens the confirmation and does nothing else.
*
* That is the whole design of the binding: one keystroke from a
* focused row, a key that *asks* is defensible and a key that
* *acts* is not — so this is the same dialog the menu command
* opens, reached by a different route.
*/
private handleShortcutDelete = (): void => {
const filePaths = this.selection.getSelectedKeysOrdered();
if (filePaths.length === 0) return;
void this.removeFromLibrary(filePaths);
};
/** Enter plays the selection — the `tracklist.play` binding, which
* has existed in the defaults and in Settings since it was written
* and has never had anything on the other end of it. */
@@ -1639,12 +1663,79 @@ export class TrackList
void this.openBatchTrackDetails(filePaths);
}
break;
case 'remove-from-library':
// The only destructive command in this menu: it asks
// first, and it keeps the selection until the user has
// answered — the dialog names a count, and clearing the
// selection under it would make that count a claim
// about nothing.
this.ctxMenu.close();
void this.removeFromLibrary(filePaths);
return;
}
this.selection.clear();
this.ctxMenu.close();
}
/**
* "Remove from library", behind a confirmation that says what it
* does *and* what it does not.
*
* The second half is the point. This deletes the database rows and
* stops the scanner importing those paths again; the audio files
* are left exactly where they are. A user who reads "remove" as
* "delete" and finds their music gone would have been failed by the
* copy, not by the operation — so the copy says so in the impact
* line, where the consequence of every other destructive action in
* the app is written.
*/
private async removeFromLibrary(filePaths: string[]) {
const count = filePaths.length;
const only =
count === 1
? tracksByFilePath(this.tracks).get(filePaths[0]!)
: undefined;
const ok = await confirmAction({
title:
count === 1
? `Remove “${only?.TrackName ?? filePaths[0]!}” from the library?`
: `Remove ${count.toLocaleString()} tracks from the library?`,
message:
count === 1
? 'It is removed from YellowJacket and will not be added' +
' back by a future scan.'
: 'They are removed from YellowJacket and will not be' +
' added back by a future scan.',
impact:
count === 1
? 'The file is not deleted — it stays on disk exactly' +
' where it is. A full rescan brings it back.'
: 'The files are not deleted — they stay on disk exactly' +
' where they are. A full rescan brings them back.',
confirmLabel:
count === 1
? 'Remove track'
: `Remove ${count.toLocaleString()} tracks`,
danger: true,
});
if (!ok) return;
try {
await RemoveFromLibrary(filePaths);
this.selection.clear();
} catch (error) {
console.error('Error removing tracks from library:', error);
notificationStore.persistent({
title: 'Could not remove from library',
text: `${count === 1 ? 'That track is' : `Those ${count.toLocaleString()} tracks are`} still in your library. ${describeError(error)}`,
});
}
}
private onContextMenuFavoriteToggle() {
const filePaths =
this.selection.getSelectedKeysOrdered();
@@ -2105,6 +2196,20 @@ export class TrackList
></wa-icon>
Track Details
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove-from-library',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Library
</wa-dropdown-item>
</div>
`
: nothing}