diff --git a/frontend/src/components/autotag-view/autotag-view.ts b/frontend/src/components/autotag-view/autotag-view.ts index 13c62a3..e9a51fa 100644 --- a/frontend/src/components/autotag-view/autotag-view.ts +++ b/frontend/src/components/autotag-view/autotag-view.ts @@ -23,6 +23,9 @@ import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; import { inlineDiff, normalizeStrict, isCosmeticDiff } from '../../utils/text-diff'; import { libraryStore } from '../../store/library-store'; +import { notificationStore } from '../../store/notification-store'; +import { describeError, explainError } from '../../utils/describe-error'; +import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; type PendingItem = autotagservice.PendingItem; type ScoreView = autotagservice.ScoreView; @@ -97,7 +100,13 @@ interface LengthDiffDetail { * leave · U paste URL · ↑↓ navigate folders · Esc close dialogs. */ @customElement('autotag-view') -export class AutotagView extends LitElement { +export class AutotagView extends ViewLifecycleMixin(LitElement) { + /* The A/S/L/U/F and arrow keys are registered shortcuts in the + * `autotag` panel scope, not a document listener of this view's own. + * Two document keydown handlers with no arbitration is finding H-2: + * on this page `s` both skipped the album and toggled shuffle. */ + protected override shortcutScope = 'autotag'; + static override readonly styles = [ designTokens, css` @@ -1179,14 +1188,38 @@ export class AutotagView extends LitElement { // render the default gray question-mark icon. @state() private applyJobs: Map = new Map(); + private queueStarted = false; private unsubscribeLibraryStore?: () => void; private unsubscribeApplyEvents: Array<() => void> = []; private currentLibraryFilter: number | null = null; - override connectedCallback(): void { - super.connectedCallback(); - document.addEventListener('keydown', this.onKeydown); - document.addEventListener('mousedown', this.onDocumentClickForMenu); + /** Everything here is torn down when the view leaves the screen, not + * when it is disconnected — which never happens, because the view + * is cached (see utils/view-lifecycle.ts). */ + protected override onViewActivate(): void { + this.listenWhileActive(document, 'keydown', this.onKeydown as EventListener); + this.listenWhileActive( + document, + 'mousedown', + this.onDocumentClickForMenu as EventListener, + ); + + for (const [event, handler] of [ + ['shortcut:autotag-apply', () => void this.onApply()], + ['shortcut:autotag-skip', () => void this.onSkip()], + ['shortcut:autotag-leave', () => void this.onLeave()], + ['shortcut:autotag-paste', () => { this.dialog = 'paste'; }], + ['shortcut:autotag-search', () => this.openSearch()], + ['shortcut:autotag-next', () => void this.navigateFolder(1)], + ['shortcut:autotag-previous', () => void this.navigateFolder(-1)], + ] as Array<[string, () => void]>) { + this.listenWhileActive(document, event, () => { + // A dialog owns the keyboard while it is open. + if (this.dialog !== 'none') return; + handler(); + }); + } + // Track the global library filter so the autotag queue // shows only folders from the currently-selected library. // null means "all libraries", which the backend treats as @@ -1201,7 +1234,7 @@ export class AutotagView extends LitElement { // Listen for per-job progress events so the sidebar can // render running/completed/failed indicators. EventsOn - // returns an unsubscribe; collected and called on disconnect. + // returns an unsubscribe; collected and called on deactivate. this.unsubscribeApplyEvents.push( EventsOn(Events.AutotagApplyStarted, (data: { groupKey: string; total: number }) => { this.onApplyStarted(data); @@ -1228,14 +1261,21 @@ export class AutotagView extends LitElement { }), ); - void this.startQueue(); + // Starting the queue resets the selection and refetches + // candidates over the network, so it happens once. Returning to + // the page only needs the folder list, which is local and may + // have moved while the page was away. + if (this.queueStarted) { + void this.loadFolders(); + } else { + this.queueStarted = true; + void this.startQueue(); + } } - override disconnectedCallback(): void { - super.disconnectedCallback(); - document.removeEventListener('keydown', this.onKeydown); - document.removeEventListener('mousedown', this.onDocumentClickForMenu); + protected override onViewDeactivate(): void { this.unsubscribeLibraryStore?.(); + this.unsubscribeLibraryStore = undefined; for (const fn of this.unsubscribeApplyEvents) fn(); this.unsubscribeApplyEvents = []; if (this.prefetchRefreshTimer !== undefined) { @@ -1279,17 +1319,44 @@ export class AutotagView extends LitElement { private async onApplyFinished({ groupKey, succeeded, failed, error }: { groupKey: string; succeeded: number; failed: number; error: string }): Promise { const allFailed = error !== '' || (succeeded === 0 && failed > 0); + + // Some files carry the new tags and some the old, and there is + // no way to discover that later: Blocking, by the plan's rule + // (errors.C3). + if (succeeded > 0 && failed > 0) { + this.updateApplyJob(groupKey, { + state: 'failed', + error: `${failed} of ${succeeded + failed} tracks failed`, + }); + notificationStore.blocking({ + key: `autotag-partial:${groupKey}`, + title: 'This folder was only partly retagged', + text: `${succeeded} of ${succeeded + failed} tracks were written; ${failed} were not. The folder now holds a mix of old and new tags.`, + detail: error || undefined, + }); + await this.loadFolders(); + + return; + } + if (allFailed) { this.updateApplyJob(groupKey, { state: 'failed', error: error || `${failed} of ${failed} tracks failed`, }); - // Toast the error so the user sees it. - this.errorMessage = error - ? `Apply failed: ${error}` - : `Apply failed: ${failed} of ${failed} tracks could not be written.`; + // Nothing was written, so nothing is inconsistent: this is + // something to retry, not something to interrupt for. + notificationStore.persistent({ + key: 'autotag-apply', + title: 'Tags not written', + text: error + ? explainError(error, 'That folder could not be retagged.') + : `None of the ${failed} tracks in that folder could be written.`, + detail: error || undefined, + }); // Refresh so the row picks up any DB state change. await this.loadFolders(); + return; } @@ -1327,16 +1394,44 @@ export class AutotagView extends LitElement { private onWarningCancel = () => { this.dialog = 'none'; }; private onWarningContinue = async () => { if (!this.current) return; + this.markWarningAcked(this.current.libraryId); - await AckLibraryWarning(this.current.libraryId); - this.dialog = 'none'; + + // Unwrapped, a rejection here meant the lines after it — the + // one that closes the dialog — never ran, and the dialog sat + // there forever (errors.m6). + try { + await AckLibraryWarning(this.current.libraryId); + } catch (err) { + console.error('autotag: could not record the warning ack', err); + this.errorMessage = describeError( + err, + 'That acknowledgement could not be saved.', + ); + } finally { + this.dialog = 'none'; + } + await this.executeApply(); }; private onLeaveCancel = () => { this.dialog = 'none'; }; private onLeaveConfirm = async () => { if (!this.current) return; + this.dialog = 'none'; - await LeaveAsIs(this.current.groupKey); + + try { + await LeaveAsIs(this.current.groupKey); + } catch (err) { + console.error('autotag: leave-as-is failed', err); + this.errorMessage = describeError( + err, + 'That folder could not be left as it is.', + ); + + return; + } + await this.refreshAfterAction(); }; @@ -1364,7 +1459,11 @@ export class AutotagView extends LitElement { this.searchKind, query, this.searchArtist.trim(), ); } catch (err) { - this.searchError = `Search failed: ${(err as Error).message}`; + console.error('autotag: candidate search failed', err); + this.searchError = describeError( + err, + 'The catalog search did not answer.', + ); this.searchResults = []; } finally { this.searchLoading = false; @@ -1380,7 +1479,11 @@ export class AutotagView extends LitElement { this.score = await SelectSearchCandidate(groupKey, hit.kind, hit.mbid); this.selectedCandidateIdx = 0; } catch (err) { - this.errorMessage = `Failed to load candidate: ${(err as Error).message}`; + console.error('autotag: could not load candidate', err); + this.errorMessage = describeError( + err, + 'That candidate could not be loaded.', + ); } finally { this.loading = false; } @@ -1399,7 +1502,11 @@ export class AutotagView extends LitElement { await this.loadFolders(); await this.reconcileSelection(); } catch (err) { - this.errorMessage = `Clear completed failed: ${(err as Error).message}`; + console.error('autotag: clear completed failed', err); + this.errorMessage = describeError( + err, + 'The completed folders could not be cleared.', + ); } }; @@ -1470,7 +1577,11 @@ export class AutotagView extends LitElement { const list = await ListPendingFolders(this.libraryFilterID()); this.folders = list ?? []; } catch (e) { - this.errorMessage = `Failed to load pending folders: ${(e as Error).message}`; + console.error('autotag: could not load pending folders', e); + this.errorMessage = describeError( + e, + 'The pending folders could not be loaded.', + ); } } @@ -1517,7 +1628,11 @@ export class AutotagView extends LitElement { await this.loadCandidates(this.current.groupKey); } } catch (e) { - this.errorMessage = `Failed to load folder: ${(e as Error).message}`; + console.error('autotag: could not load folder', e); + this.errorMessage = describeError( + e, + 'That folder could not be loaded.', + ); } } @@ -1548,7 +1663,11 @@ export class AutotagView extends LitElement { this.score = result; } catch (e) { if (this.current?.groupKey !== groupKey) return; - this.errorMessage = `Failed to score candidates: ${(e as Error).message}`; + console.error('autotag: could not score candidates', e); + this.errorMessage = describeError( + e, + 'The candidates for this folder could not be scored.', + ); } finally { this.loading = false; } @@ -1647,8 +1766,14 @@ export class AutotagView extends LitElement { // from the UI, but defensive); other errors clear the // running state and surface a toast. if (!msg.includes('apply already in flight')) { + console.error('autotag: apply failed to start', e); this.updateApplyJob(groupKey, { state: 'failed', error: msg }); - this.errorMessage = `Apply failed: ${msg}`; + notificationStore.persistent({ + key: 'autotag-apply', + title: 'Tags not written', + text: explainError(e, 'That folder could not be retagged.'), + detail: msg, + }); } }); @@ -1659,17 +1784,43 @@ export class AutotagView extends LitElement { private async onSkip(): Promise { if (!this.current) return; - await Skip(this.current.groupKey); + + try { + await Skip(this.current.groupKey); + } catch (err) { + console.error('autotag: skip failed', err); + this.errorMessage = describeError( + err, + 'That folder could not be skipped.', + ); + + return; + } + await this.refreshAfterAction(); } private async onLeave(): Promise { if (!this.current) return; + if (this.topScore() < CONFIDENT_SCORE) { this.dialog = 'leave'; + return; } - await LeaveAsIs(this.current.groupKey); + + try { + await LeaveAsIs(this.current.groupKey); + } catch (err) { + console.error('autotag: leave-as-is failed', err); + this.errorMessage = describeError( + err, + 'That folder could not be left as it is.', + ); + + return; + } + await this.refreshAfterAction(); } @@ -1684,7 +1835,11 @@ export class AutotagView extends LitElement { this.dialog = 'none'; this.pasteURL = ''; } catch (e) { - this.errorMessage = `Paste URL failed: ${(e as Error).message}`; + console.error('autotag: paste URL failed', e); + this.errorMessage = describeError( + e, + 'That release URL could not be used.', + ); } finally { this.loading = false; } @@ -1703,47 +1858,16 @@ export class AutotagView extends LitElement { } } + /** Escape closes this view's hand-rolled dialogs. Everything else + * the page binds is a registered shortcut in the `autotag` panel + * scope, so the shortcut service is the only thing deciding what a + * key means — including on the pages this view is not on. */ private onKeydown = (e: KeyboardEvent): void => { - const target = e.target as HTMLElement; - if (target && ( - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable - )) { - if (e.key === 'Escape' && this.dialog !== 'none') { - e.preventDefault(); - this.dialog = 'none'; - this.pasteURL = ''; - } - return; - } + if (e.key !== 'Escape' || this.dialog === 'none') return; - if (e.key === 'Escape') { - if (this.dialog !== 'none') { - e.preventDefault(); - this.dialog = 'none'; - this.pasteURL = ''; - } - return; - } - - if (this.dialog !== 'none') return; - - switch (e.key.toLowerCase()) { - case 'a': e.preventDefault(); void this.onApply(); break; - case 's': e.preventDefault(); void this.onSkip(); break; - case 'l': e.preventDefault(); void this.onLeave(); break; - case 'u': e.preventDefault(); this.dialog = 'paste'; break; - case 'f': e.preventDefault(); this.openSearch(); break; - case 'arrowdown': - e.preventDefault(); - void this.navigateFolder(1); - break; - case 'arrowup': - e.preventDefault(); - void this.navigateFolder(-1); - break; - } + e.preventDefault(); + this.dialog = 'none'; + this.pasteURL = ''; }; /* ── Version clustering ── */ diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index f5a4ddd..3412084 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -14,7 +14,10 @@ import { GetScanConcurrency, SetScanConcurrency, } from '@go/config/Config'; +import { GetIndexStatus } from '@go/explore/Service'; import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; +import { notificationStore } from '@store/notification-store'; +import { describeError, explainError } from '@utils/describe-error'; import type { library } from '@go/models'; import { ThemeController } from '@store/controllers/theme-controller'; import { TrackListController } from '@store/controllers/tracklist-controller'; @@ -22,6 +25,7 @@ import { FavoritesController } from '@store/controllers/favorites-controller'; import { GetAllPlaylists } from '@go/playlist/Service'; import type { playlist } from '@go/models'; import { Events } from '../../events'; +import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; import type { ConfigFieldChangeEvent } from './config-field'; import type { BackgroundShade } from '@store/theme-store'; import type { IconStyle } from '@store/favorites-store'; @@ -45,7 +49,7 @@ const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed'; // =================================================================== @customElement('config-page') -export class ConfigPage extends LitElement { +export class ConfigPage extends ViewLifecycleMixin(LitElement) { // --- Theme controller for reading/writing theme state --- private themeCtrl = new ThemeController(this); @@ -164,6 +168,48 @@ export class ConfigPage extends LitElement { scope: 'panel:track-list', defaultKey: 'Delete', }, + 'autotag.apply': { + label: 'Apply Match', + category: 'Autotag', + scope: 'panel:autotag', + defaultKey: 'A', + }, + 'autotag.skip': { + label: 'Skip Folder', + category: 'Autotag', + scope: 'panel:autotag', + defaultKey: 'S', + }, + 'autotag.leave': { + label: 'Leave As Is', + category: 'Autotag', + scope: 'panel:autotag', + defaultKey: 'L', + }, + 'autotag.paste': { + label: 'Paste Release URL', + category: 'Autotag', + scope: 'panel:autotag', + defaultKey: 'U', + }, + 'autotag.search': { + label: 'Search Candidates', + category: 'Autotag', + scope: 'panel:autotag', + defaultKey: 'F', + }, + 'autotag.next': { + label: 'Next Folder', + category: 'Autotag', + scope: 'panel:autotag', + defaultKey: 'Down', + }, + 'autotag.previous': { + label: 'Previous Folder', + category: 'Autotag', + scope: 'panel:autotag', + defaultKey: 'Up', + }, }; // --- Now Playing state --- @@ -179,20 +225,20 @@ export class ConfigPage extends LitElement { @state() private removingLibraryId: number | null = null; @state() private removalImpact: library.RemovalImpact | null = null; @state() private isRemoving = false; - @state() private toastMessage = ''; - @state() private toastVisible = false; @state() private activeMenuId: number | null = null; @state() private concurrencyMode = 'auto'; @state() private indexStatus: explore.IndexStatus | null = null; + /** Three states, not one: the panel used to say "Loading status…" + * for the entire session, because the only thing that ever set + * `indexStatus` was an event that fires on *change* (errors.M3). */ + @state() private indexStatusFailed = false; @state() private shortcutConflict: { newAction: string; newKey: string; existingAction: string; } | null = null; - private toastTimer?: ReturnType; private cancelIndexStatus?: () => void; - private indexPollTimer?: ReturnType; private cancelLibraryAdded?: () => void; private cancelLibraryRenamed?: () => void; private cancelLibraryRemoved?: () => void; @@ -805,35 +851,6 @@ export class ConfigPage extends LitElement { ); } - .toast { - position: fixed; - bottom: 80px; - left: 50%; - transform: translateX(-50%); - background: var(--yj-bg-surface, #2a2a2a); - color: var(--yj-text-primary, #fff); - padding: 0.75em 1.5em; - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); - font-size: var(--yj-text-sm, 13px); - z-index: 2000; - border: 1px solid var(--yj-border, #444); - animation: toast-in 0.2s ease-out; - } - - @keyframes toast-in { - from { - opacity: 0; - transform: translateX(-50%) - translateY(10px); - } - to { - opacity: 1; - transform: translateX(-50%) - translateY(0); - } - } - .spinner { display: inline-block; width: 14px; @@ -982,14 +999,31 @@ export class ConfigPage extends LitElement { color: var(--yj-text-tertiary, #888); } + .index-status-failed { + align-items: center; + color: var(--yj-text-secondary, #b3b3b3); + display: flex; + font-size: var(--yj-text-sm); + gap: 0.5em; + } + + .index-status-failed .link { + background: none; + border: none; + color: var(--yj-accent, #ffd43b); + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: underline; + } + `; // =================================================================== // LIFECYCLE // =================================================================== - override connectedCallback(): void { - super.connectedCallback(); + protected override onViewActivate(): void { void this.loadLibraries(); void this.loadPlaylists(); this.scrollMode = @@ -1010,28 +1044,27 @@ export class ConfigPage extends LitElement { () => void this.loadLibraries(), ); - document.addEventListener('click', this.handleDocumentClick); + this.listenWhileActive(document, 'click', this.handleDocumentClick); // Listen for index status events (pushed from Go, no binding calls). this.cancelIndexStatus = EventsOn( Events.IndexStatusChanged, (status: explore.IndexStatus) => { - console.log('IndexStatusChanged event received', status); this.indexStatus = status; + this.indexStatusFailed = false; }, ); + + // …and pull the current one, because that event only fires when + // a build *changes* state, and the steady state is no build. + void this.loadIndexStatus(); } - override disconnectedCallback(): void { - super.disconnectedCallback(); + protected override onViewDeactivate(): void { this.cancelLibraryAdded?.(); this.cancelLibraryRenamed?.(); this.cancelLibraryRemoved?.(); - document.removeEventListener('click', this.handleDocumentClick); - - if (this.toastTimer) clearTimeout(this.toastTimer); - if (this.indexPollTimer) clearInterval(this.indexPollTimer); this.cancelIndexStatus?.(); } @@ -1058,17 +1091,29 @@ export class ConfigPage extends LitElement { // =================================================================== private handleAddLibrary = async (): Promise => { - try { - const dir = await DirectoryPicker(); + let dir = ''; - if (dir) { - await AddLibrary(dir); - } + try { + dir = await DirectoryPicker(); + + if (!dir) return; + + await AddLibrary(dir); } catch (err) { - console.error( - 'Failed to add library:', - err, - ); + console.error('Failed to add library:', err); + notificationStore.persistent({ + key: 'library-add', + title: 'Library not added', + text: explainError( + err, + 'That folder could not be added as a library.', + ), + detail: dir, + action: { + label: 'Try again', + run: () => void this.handleAddLibrary(), + }, + }); } }; @@ -1083,10 +1128,21 @@ export class ConfigPage extends LitElement { e.preventDefault(); if (this.editingLibraryId !== null && this.editingName.trim()) { + const name = this.editingName.trim(); + try { - await RenameLibrary(this.editingLibraryId, this.editingName.trim()); + await RenameLibrary(this.editingLibraryId, name); } catch (err) { console.error('Failed to rename library:', err); + notificationStore.persistent({ + key: 'library-rename', + title: 'Library not renamed', + text: explainError( + err, + `“${name}” could not be used as a library name.`, + ), + detail: String(err), + }); } } @@ -1130,16 +1186,23 @@ export class ConfigPage extends LitElement { this.removingLibraryId = null; this.removalImpact = null; this.isRemoving = false; - this.showToast( - `Removed '${libName}' (${summary?.tracksDeleted ?? 0} tracks deleted)`, - ); + notificationStore.transient({ + tone: 'success', + key: 'library-remove', + text: `Removed “${libName}” — ${summary?.tracksDeleted ?? 0} tracks deleted.`, + }); void this.loadLibraries(); } catch (err) { this.isRemoving = false; this.removingLibraryId = null; this.removalImpact = null; console.error('Failed to remove library:', err); - this.showToast(`Failed to remove '${libName}': ${String(err)}`); + notificationStore.persistent({ + key: 'library-remove', + title: 'Library not removed', + text: `Could not remove “${libName}”. ${describeError(err)}`, + detail: String(err), + }); } }; @@ -1165,15 +1228,14 @@ export class ConfigPage extends LitElement { } }; - private showToast(message: string): void { - this.toastMessage = message; - this.toastVisible = true; - - if (this.toastTimer) clearTimeout(this.toastTimer); - - this.toastTimer = setTimeout(() => { - this.toastVisible = false; - }, 8000); + private async loadIndexStatus(): Promise { + try { + this.indexStatus = await GetIndexStatus(); + this.indexStatusFailed = false; + } catch (err) { + console.error('Failed to read index status:', err); + this.indexStatusFailed = true; + } } private handleConcurrencyChange = ( @@ -1184,12 +1246,19 @@ export class ConfigPage extends LitElement { SetScanConcurrency(mode) .then(() => { this.concurrencyMode = mode; - this.showToast( - 'Storage type saved. Takes effect on next scan.', - ); + notificationStore.transient({ + tone: 'success', + key: 'storage-type', + text: 'Storage type saved. Takes effect on next scan.', + }); }) .catch((err: unknown) => { - this.showToast(`Failed to save storage type: ${err}`); + console.error('Failed to save storage type:', err); + notificationStore.transient({ + key: 'storage-type', + text: `Could not save the storage type. ${describeError(err)}`, + detail: String(err), + }); }); }; @@ -1511,7 +1580,7 @@ export class ConfigPage extends LitElement { ? html`${t.detail}` : nothing} ${t.state === 'error' - ? html`${t.error}` + ? html`${describeError(t.error, 'This part of the index could not be built.')}` : nothing} `, @@ -1527,7 +1596,18 @@ export class ConfigPage extends LitElement { ? html`
Waiting for index build…
` : nothing} ` - : html`
Loading status…
`} + : this.indexStatusFailed + ? html`
+ The index status could not be read. + +
` + : html`
Loading status…
`} `; @@ -2133,10 +2213,6 @@ export class ConfigPage extends LitElement { ` : nothing} - - ${this.toastVisible - ? html`
${this.toastMessage}
` - : nothing} `; } diff --git a/frontend/src/components/config-page/download-clients.ts b/frontend/src/components/config-page/download-clients.ts index 2efa1d8..3581e60 100644 --- a/frontend/src/components/config-page/download-clients.ts +++ b/frontend/src/components/config-page/download-clients.ts @@ -18,6 +18,8 @@ import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config'; import { SetPreferences } from '@go/download/Service'; import type { download } from '@go/models'; +import { describeError, explainError } from '@utils/describe-error'; +import { confirmAction } from '@components/confirm-dialog/confirm-dialog'; import './config-section'; /** @@ -416,7 +418,7 @@ export class DownloadClients extends LitElement { size="small" appearance="plain" variant="danger" - @click=${() => this.deleteProvider(provider)} + @click=${() => void this.deleteProvider(provider)} > Remove @@ -643,7 +645,11 @@ export class DownloadClients extends LitElement { this.cancelEdit(); } catch (err) { - this.errorMessage = String(err); + console.error('Failed to add download client:', err); + this.errorMessage = explainError( + err, + 'That client could not be saved.', + ); } }; @@ -670,7 +676,11 @@ export class DownloadClients extends LitElement { this.cancelEdit(); } catch (err) { - this.errorMessage = String(err); + console.error('Failed to save download client:', err); + this.errorMessage = explainError( + err, + 'Those changes could not be saved.', + ); } } @@ -685,13 +695,33 @@ export class DownloadClients extends LitElement { return out; } + /** + * Removing a client discards its stored credentials, which cannot + * be recovered — and it used to happen on one click (errors.m4). + */ private async deleteProvider(provider: DownloadProvider) { + const ok = await confirmAction({ + title: `Remove “${provider.name}”?`, + message: + 'YellowJacket will stop using this client for downloads.', + impact: + 'Its stored credentials are deleted and cannot be recovered.', + confirmLabel: 'Remove client', + danger: true, + }); + + if (!ok) return; + this.errorMessage = ''; try { await downloadStore.deleteProvider(provider.id); } catch (err) { - this.errorMessage = String(err); + console.error('Failed to remove download client:', err); + this.errorMessage = describeError( + err, + 'That client could not be removed.', + ); } } @@ -706,6 +736,9 @@ export class DownloadClients extends LitElement { [provider.id]: { ok: true, message: 'Connected.' }, }; } catch (err) { + // Deliberately verbatim: a connection test's error is the + // user's debugging tool for a misconfigured client, and is + // the documented exception to describeError() (errors.M9). this.testResults = { ...this.testResults, [provider.id]: { ok: false, message: String(err) }, @@ -740,7 +773,11 @@ export class DownloadClients extends LitElement { await SetPreferences(this.prefs); this.prefsSaved = true; } catch (err) { - this.prefsError = String(err); + console.error('Failed to save download preferences:', err); + this.prefsError = describeError( + err, + 'Those preferences could not be saved.', + ); } finally { this.prefsSaving = false; } diff --git a/frontend/src/components/download-picker/download-picker.ts b/frontend/src/components/download-picker/download-picker.ts index 226ac63..3888985 100644 --- a/frontend/src/components/download-picker/download-picker.ts +++ b/frontend/src/components/download-picker/download-picker.ts @@ -9,6 +9,7 @@ import type { DownloadCandidate } from '@store/download-store'; import { downloadStore } from '@store/download-store'; import type { download } from '@go/models'; import './candidate-row'; +import { explainError } from '@utils/describe-error'; /** * The "find this album" dialog: searches every enabled download client, @@ -123,8 +124,17 @@ export class DownloadPicker extends LitElement { } } + /** + * Monotonic search version. Closing and reopening the dialog for a + * different album used to let the first search's result overwrite + * the second's (errors.m8); the guard is `explore-view`'s. + */ + private searchVersion = 0; + /** Runs the search that populates the dialog. */ private async search(): Promise { + const version = ++this.searchVersion; + this.searching = true; this.errorMessage = ''; this.candidates = []; @@ -141,13 +151,23 @@ export class DownloadPicker extends LitElement { expected: this.expected ?? [], } as download.SearchRequest); + if (version !== this.searchVersion) return; + this.downloadId = result.downloadId; this.candidates = result.candidates ?? []; this.autoPicked = result.autoPicked; } catch (err) { - this.errorMessage = String(err); + if (version !== this.searchVersion) return; + + console.error('download search failed', err); + this.errorMessage = explainError( + err, + 'The search did not finish.', + ); } finally { - this.searching = false; + if (version === this.searchVersion) { + this.searching = false; + } } } @@ -161,7 +181,11 @@ export class DownloadPicker extends LitElement { await downloadStore.pick(this.downloadId, event.detail.candidateId); this.close(); } catch (err) { - this.errorMessage = String(err); + console.error('download pick failed', err); + this.errorMessage = explainError( + err, + 'That download could not be started.', + ); } finally { this.picking = false; } diff --git a/frontend/src/components/downloads-view/downloads-view.ts b/frontend/src/components/downloads-view/downloads-view.ts index 7cfd315..53c833a 100644 --- a/frontend/src/components/downloads-view/downloads-view.ts +++ b/frontend/src/components/downloads-view/downloads-view.ts @@ -6,6 +6,10 @@ import { designTokens } from '../../styles/tokens.css'; import { downloadStore, stateLabel } from '@store/download-store'; import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store'; import { libraryStore } from '@store/library-store'; +import { notificationStore } from '@store/notification-store'; +import { describeError } from '@utils/describe-error'; +import { confirmAction } from '../confirm-dialog/confirm-dialog'; +import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; type Tab = 'requests' | 'downloads'; @@ -22,7 +26,7 @@ type Tab = 'requests' | 'downloads'; * attempt history nothing rendered before this page existed. */ @customElement('downloads-view') -export class DownloadsView extends LitElement { +export class DownloadsView extends ViewLifecycleMixin(LitElement) { @state() private tab: Tab = 'requests'; @state() private requests: Request[] = []; @@ -39,7 +43,6 @@ export class DownloadsView extends LitElement { /** Ticks so "next check in …" ages while the page is open. */ @state() private nowMs = Date.now(); - private clockTimer?: ReturnType; private unsubscribe: (() => void) | null = null; @@ -196,9 +199,7 @@ export class DownloadsView extends LitElement { `, ]; - override connectedCallback(): void { - super.connectedCallback(); - + protected override onViewActivate(): void { this.unsubscribe = downloadStore.subscribe(() => { this.requests = downloadStore.requests; this.downloads = downloadStore.downloads; @@ -212,18 +213,16 @@ export class DownloadsView extends LitElement { }); // A "next check" that never moves reads as a stuck page, so the - // relative times re-render on their own. - this.clockTimer = setInterval(() => { + // relative times re-render on their own — while the page is on + // screen, where a re-render can be seen. + this.intervalWhileActive(() => { this.nowMs = Date.now(); }, 30_000); } - override disconnectedCallback(): void { - super.disconnectedCallback(); - + protected override onViewDeactivate(): void { this.unsubscribe?.(); this.unsubscribe = null; - clearInterval(this.clockTimer); } override render() { @@ -295,8 +294,7 @@ export class DownloadsView extends LitElement { - void downloadStore.clearSatisfiedRequests()} + @click=${() => void this.clearSatisfied()} > Clear found @@ -450,11 +448,7 @@ export class DownloadsView extends LitElement { - void downloadStore.pauseRequest( - request.id, - request.state !== 'paused', - )} + @click=${() => void this.pause(request)} > ${request.state === 'paused' ? 'Resume' : 'Pause'} @@ -470,13 +464,77 @@ export class DownloadsView extends LitElement { void downloadStore.removeRequest(request.id)} + aria-label="Stop following" + @click=${() => void this.removeRequest(request)} > `; } + /** What to call a request in a sentence. */ + private static describeRequest(request: Request): string { + return request.artist || request.title || request.mbid || 'that request'; + } + + /** + * Removing a durable request used to be one unconfirmed click with + * the promise thrown away (errors.M7) — on a subscription the user + * may have been building for months. + */ + private async removeRequest(request: Request): Promise { + const name = DownloadsView.describeRequest(request); + const ok = await confirmAction({ + title: `Stop following “${name}”?`, + message: + request.scope === 'all' + ? 'YellowJacket will stop looking for this artist’s releases.' + : 'YellowJacket will stop looking for this release.', + impact: 'Anything already downloaded stays in your library.', + confirmLabel: 'Stop following', + danger: true, + }); + + if (!ok) return; + + try { + await downloadStore.removeRequest(request.id); + } catch (err) { + this.report(`remove “${name}”`, err); + } + } + + private async pause(request: Request): Promise { + const paused = request.state !== 'paused'; + + try { + await downloadStore.pauseRequest(request.id, paused); + } catch (err) { + this.report( + `${paused ? 'pause' : 'resume'} “${DownloadsView.describeRequest(request)}”`, + err, + ); + } + } + + private async clearSatisfied(): Promise { + try { + await downloadStore.clearSatisfiedRequests(); + } catch (err) { + this.report('clear the found requests', err); + } + } + + /** Persistent: the row is still there, and retrying is the point. */ + private report(what: string, err: unknown): void { + console.error(`downloads: could not ${what}`, err); + notificationStore.persistent({ + key: 'download-request', + text: `Could not ${what}. ${describeError(err)}`, + detail: String(err), + }); + } + /** Widens or narrows what an artist subscription covers. */ private async toggleScope(request: Request): Promise { try { diff --git a/frontend/src/components/explore-album-details/explore-album-details.ts b/frontend/src/components/explore-album-details/explore-album-details.ts index e387488..60fad2e 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -15,6 +15,7 @@ type MBTrack = explore.MBTrack; import { exploreCache } from '../../store/explore-cache'; import { libraryStore } from '../../store/library-store'; import { artistLink, exploreLinkStyles } from '../../utils/explore-link'; +import { describeError } from '../../utils/describe-error'; import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; @@ -664,22 +665,13 @@ export class ExploreAlbumDetails extends LitElement { // Local-only album (no MBID) — populate entirely from library. if (!mbid && this.localAlbumId) { - console.log( - `[explore-album] loading local-only: "${this.albumName}" (id=${this.localAlbumId})`, - ); await this.hydrateLocalOnly(); - console.log( - `[explore-album] loaded (local-only): "${this.albumName}"`, - ); return; } - console.log( - `[explore-album] loading: "${this.albumName}" (${mbid})`, - ); // Phase 0: hydrate from explore cache (instant). const cached = exploreCache.getAlbum(mbid); @@ -692,7 +684,6 @@ export class ExploreAlbumDetails extends LitElement { primaryType: 'Album', } as MBReleaseGroup; this.loadingInfo = false; - console.log(`[explore-album] hydrated from cache: "${cached.title}"`); } // Phase 1: hydrate tracklist from local library if available. @@ -712,9 +703,6 @@ export class ExploreAlbumDetails extends LitElement { // Fire cover art resolution immediately — doesn't wait for release group. this.resolveCoverArt(); - console.log( - `[explore-album] data requests fired: "${this.albumName}" (${mbid})`, - ); } /** @@ -874,9 +862,6 @@ export class ExploreAlbumDetails extends LitElement { this.buildClusters(); this.loadingReleases = false; - console.log( - `[explore-album] hydrated ${localTracks.length} tracks from library`, - ); return true; } @@ -908,9 +893,11 @@ export class ExploreAlbumDetails extends LitElement { this.releaseGroup = await LookupReleaseGroup(mbid); this.resolveCoverArt(); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.errorInfo = msg; - console.error(`[explore-album] LookupReleaseGroup error: ${msg}`); + console.error('[explore-album] LookupReleaseGroup error', err); + this.errorInfo = describeError( + err, + 'The catalog did not answer for this album.', + ); } finally { this.loadingInfo = false; } @@ -965,9 +952,11 @@ export class ExploreAlbumDetails extends LitElement { this.catalogPending = false; } } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.errorReleases = msg; - console.error(`[explore-album] BrowseReleases error: ${msg}`); + console.error('[explore-album] BrowseReleases error', err); + this.errorReleases = describeError( + err, + 'The catalog did not answer for this album\u2019s versions.', + ); this.loadingReleases = false; this.catalogPending = false; } diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts index 403cf9a..dba15a3 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -27,6 +27,7 @@ import { libraryStore } from '../../store/library-store'; import { downloadStore } from '../../store/download-store'; import '@awesome.me/webawesome/dist/components/button/button.js'; import { trackLink, exploreLinkStyles } from '../../utils/explore-link'; +import { describeError } from '../../utils/describe-error'; import { GetAlbumsByArtist } from '@go/library/Library'; import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; @@ -1014,9 +1015,6 @@ export class ExploreArtistDetails extends LitElement { // Local-only artist (no MBID) — populate from library store. if (!mbid && this.localArtistId) { - console.log( - `[explore-artist] loading local-only: "${this.artistName}" (id=${this.localArtistId})`, - ); this.loadingArtist = false; this.loadingTracks = false; @@ -1027,16 +1025,10 @@ export class ExploreArtistDetails extends LitElement { this.hydrateLocalOnly(); - console.log( - `[explore-artist] loaded (local-only): "${this.artistName}"`, - ); return; } - console.log( - `[explore-artist] loading: "${this.artistName}" (${mbid})`, - ); // Phase 0: hydrate from caches (instant, no Go calls). this.hydrateFromCache(mbid); @@ -1068,9 +1060,6 @@ export class ExploreArtistDetails extends LitElement { // checkLibrary now runs from fetchReleaseGroups when data arrives. - console.log( - `[explore-artist] data requests fired: "${this.artistName}" (${mbid})`, - ); } /** @@ -1290,9 +1279,11 @@ export class ExploreArtistDetails extends LitElement { try { this.artist = await LookupArtist(mbid); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.errorArtist = msg; - console.error(`[explore-artist] LookupArtist error: ${msg}`); + console.error('[explore-artist] LookupArtist error', err); + this.errorArtist = describeError( + err, + 'The catalog did not answer.', + ); } finally { this.loadingArtist = false; } @@ -1326,10 +1317,7 @@ export class ExploreArtistDetails extends LitElement { void this.batchResolveTrackThumbnails(tracks, mapping); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error( - `[explore-artist] TopRecordingsForArtist error: ${msg}`, - ); + console.error('[explore-artist] TopRecordingsForArtist error', err); } finally { // An empty first pass may mean a background discography fetch // is still in flight (the artist wasn't indexed yet). Keep @@ -1419,10 +1407,7 @@ export class ExploreArtistDetails extends LitElement { // are the most likely to be clicked from the artist page. this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? []); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error( - `[explore-artist] TopReleaseGroupsForArtist error: ${msg}`, - ); + console.error('[explore-artist] TopReleaseGroupsForArtist error', err); this.topReleaseGroups = []; } finally { // See fetchTopTracks: hold the spinner while a background @@ -1462,12 +1447,12 @@ export class ExploreArtistDetails extends LitElement { // one from here is instant instead of a cold MB browse. this.prefetchReleases(rgs?.map((r) => r.mbid) ?? []); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.errorReleases = msg; - this.catalogPending = false; - console.error( - `[explore-artist] BrowseReleaseGroups error: ${msg}`, + console.error('[explore-artist] BrowseReleaseGroups error', err); + this.errorReleases = describeError( + err, + 'The catalog did not answer for this artist\u2019s albums.', ); + this.catalogPending = false; } finally { // BrowseReleaseGroups is index-first + async: an empty result on // a cold artist means the discography is still being fetched in @@ -1498,10 +1483,7 @@ export class ExploreArtistDetails extends LitElement { this.similarArtists = artists ?? []; } catch (err) { // D024: graceful degradation — silently omit similar artists on failure. - const msg = err instanceof Error ? err.message : String(err); - console.error( - `[explore-artist] SimilarArtists error: ${msg}`, - ); + console.error('[explore-artist] SimilarArtists error', err); this.similarArtists = []; } finally { // SimilarArtists is DB-first + async: an empty result on the diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index 8b2f7ba..6fd8321 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -3,13 +3,17 @@ import { customElement, state, query as litQuery } from 'lit/decorators.js'; import { designTokens } from '../../styles/tokens.css'; import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service'; import { libraryStore } from '../../store/library-store'; -import { exploreCache } from '../../store/explore-cache'; +import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '../../store/explore-cache'; import { queueStore } from '../../store/queue-store'; import { artistLink, trackLink, exploreLinkStyles } from '../../utils/explore-link'; +import { describeError } from '../../utils/describe-error'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '../library-status-indicator/library-status-indicator.js'; import '../top-results-row/top-results-row.js'; import { explore } from '@go/models'; +import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; +import { registerCacheProbe } from '../../utils/cache-stats'; +import { LRUMap } from '../../utils/lru-map'; type ThumbnailRequest = explore.ThumbnailRequest; type MBSearchResult = explore.MBSearchResult; type LyricsResult = explore.LyricsResult; @@ -25,6 +29,32 @@ const SEARCH_DEBOUNCE_MS = 180; const MAX_SECTION_RESULTS = 10; +/* + * Caps for the two art caches (`perf.M7`). + * + * Both were unbounded, on a view that never unmounts: twelve searches + * retained 8.48 MB and were still climbing 0.7 MB per search. The caps + * come from the measured cost of an entry — a cover thumbnail is ~27 kB + * of base64, an artist photo ~128 kB — so each is set to hold roughly a + * dozen searches' worth and then stop: + * + * thumbnails 96 × ~27 kB ≈ 2.6 MB ceiling + * artist images 32 × ~128 kB ≈ 4.1 MB ceiling + * + * Both are comfortably larger than one screen of results, which matters: + * a cap below the visible count would evict art that is still rendered, + * and the re-render would fetch it again immediately. A search shows at + * most `MAX_SECTION_RESULTS` artists and ~15 release groups, so these + * are ~3× and ~6× a screenful — six searches of history for covers, + * which is far more than the "go back to the previous search" the cache + * exists to serve. + * + * `ARTIST_IMAGE_CACHE_LIMIT` is shared with `explore-cache.ts`, which + * holds the *same* data URLs for the detail pages. Bounding one and + * not the other frees nothing. + */ +const THUMBNAIL_CACHE_LIMIT = 96; + /** Hash a string to a hue value 0–360 for avatar coloring. */ function nameToHue(name: string): number { @@ -79,7 +109,7 @@ function getArtistAlbumArt(artistName: string): string { } @customElement('explore-view') -export class ExploreView extends LitElement { +export class ExploreView extends ViewLifecycleMixin(LitElement) { /* ── State ── */ @state() private searchQuery = ''; @@ -96,10 +126,29 @@ export class ExploreView extends LitElement { private searchVersion = 0; /** Debounce timer for live search-as-you-type. */ private searchDebounceTimer?: ReturnType; - private thumbnailCache = new Map(); - private artistImageCache = new Map(); + private thumbnailCache = new LRUMap(THUMBNAIL_CACHE_LIMIT); + private artistImageCache = new LRUMap(ARTIST_IMAGE_CACHE_LIMIT); private libraryMBIDs = new Set(); + constructor() { + super(); + + // Registered from the constructor rather than on connect: this is + // a cached primary view, so it connects once, and a measurement + // must be able to read the caches whether or not Explore is the + // view currently on screen. + const stat = (m: LRUMap) => () => { + let chars = 0; + + for (const v of m.values()) chars += v.length; + + return { entries: m.size, chars, limit: m.limit }; + }; + + registerCacheProbe('explore.thumbnails', stat(this.thumbnailCache)); + registerCacheProbe('explore.artistImages', stat(this.artistImageCache)); + } + @litQuery('input') private inputEl!: HTMLInputElement; /* ── Styles ── */ @@ -592,9 +641,13 @@ export class ExploreView extends LitElement { override disconnectedCallback() { super.disconnectedCallback(); - if (this.searchDebounceTimer) { - clearTimeout(this.searchDebounceTimer); - } + this.cancelPendingSearch(); + } + + /** A debounced search that lands after the user has left the page is + * a query nobody asked for, against a 1.1 M-row index. */ + protected override onViewDeactivate(): void { + this.cancelPendingSearch(); } /* ── Search Logic ── */ @@ -707,19 +760,16 @@ export class ExploreView extends LitElement { this.loading = true; this.error = ''; - const startTime = performance.now(); - console.log(`[explore] search started: "${query}" (${this.searchMode})`); - // Lyrics mode: a single FTS lyric search over the library. if (this.searchMode === 'lyrics') { - void this.executeLyricsSearch(version, query, startTime); + void this.executeLyricsSearch(version, query); return; } // Offline search over the local popularity index via Wails RPC. // No network, and no owned-library seed — the index is the sole // source of catalog results. - void this.executeIndexSearch(version, query, startTime); + void this.executeIndexSearch(version, query); } /** @@ -775,7 +825,7 @@ export class ExploreView extends LitElement { return result; } - private async executeIndexSearch(version: number, query: string, startTime: number) { + private async executeIndexSearch(version: number, query: string) { try { // Local FTS index only — no network. Returns null when the // index has no hits, in which case we keep the owned-library @@ -809,19 +859,16 @@ export class ExploreView extends LitElement { this.loadThumbnails(); this.loadArtistImages(); this.checkLibrary(); - - const elapsed = (performance.now() - startTime).toFixed(0); - console.log( - `[explore] search completed: "${query}" in ${elapsed}ms — ` + - `artists=${result.artists?.length ?? 0}, ` + - `albums=${result.releaseGroups?.length ?? 0}, ` + - `tracks=${result.recordings?.length ?? 0}`, - ); } catch (err) { if (version !== this.searchVersion) return; - const message = err instanceof Error ? err.message : String(err); - this.error = message; - console.error(`[explore] search error: "${query}" — ${message}`); + + console.error(`[explore] search error: "${query}"`, err); + // Inline: this failure belongs to the results panel, and + // the panel is what the user is looking at (errors.M9). + this.error = describeError( + err, + 'The catalog search did not answer.', + ); } finally { if (version === this.searchVersion) { this.loading = false; @@ -831,22 +878,20 @@ export class ExploreView extends LitElement { /* ── Lyrics Search ── */ - private async executeLyricsSearch(version: number, query: string, startTime: number) { + private async executeLyricsSearch(version: number, query: string) { try { const hits = await SearchLyrics(query); if (version !== this.searchVersion) return; this.lyricsResults = hits ?? []; - - const elapsed = (performance.now() - startTime).toFixed(0); - console.log( - `[explore] lyrics search: "${query}" in ${elapsed}ms — ` + - `hits=${this.lyricsResults.length}`, - ); } catch (err) { if (version !== this.searchVersion) return; - this.error = err instanceof Error ? err.message : String(err); - console.error(`[explore] lyrics search error: "${query}" — ${this.error}`); + + console.error(`[explore] lyrics search error: "${query}"`, err); + this.error = describeError( + err, + 'The lyric search did not answer.', + ); } finally { if (version === this.searchVersion) { this.loading = false; diff --git a/frontend/src/components/first-run-wizard/first-run-wizard.ts b/frontend/src/components/first-run-wizard/first-run-wizard.ts index b7a3842..41262f7 100644 --- a/frontend/src/components/first-run-wizard/first-run-wizard.ts +++ b/frontend/src/components/first-run-wizard/first-run-wizard.ts @@ -7,6 +7,7 @@ import { GetAllLibrariesWithTrackCounts, } from '@go/library/Library'; import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; +import { describeError, explainError } from '@utils/describe-error'; /** * First-run setup wizard. @@ -236,7 +237,10 @@ export class FirstRunWizard extends LitElement { if (dir) this.selectedDirectory = dir; } catch (err) { - this.errorMessage = `Could not open folder picker: ${err}`; + this.errorMessage = describeError( + err, + 'The folder picker could not be opened.', + ); console.error('First-run wizard: directory picker failed:', err); } }; @@ -256,7 +260,10 @@ export class FirstRunWizard extends LitElement { this.active = false; } catch (err) { - this.errorMessage = `Could not add the folder: ${err}`; + this.errorMessage = explainError( + err, + 'That folder could not be added.', + ); console.error('First-run wizard: add library failed:', err); } finally { this.saving = false; diff --git a/frontend/src/components/genre-details/genre-details.ts b/frontend/src/components/genre-details/genre-details.ts index 61f84bc..a012a9a 100644 --- a/frontend/src/components/genre-details/genre-details.ts +++ b/frontend/src/components/genre-details/genre-details.ts @@ -12,6 +12,7 @@ import { import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; import { libraryStore } from '@store/library-store'; +import { describeError } from '@utils/describe-error'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/track-list/track-list.js'; import { designTokens } from '../../styles/tokens.css'; @@ -27,6 +28,12 @@ export class GenreDetails extends LitElement { @state() private loading = true; + /** A failed genre query used to be handed to `` as an + * empty array, so it was indistinguishable from a slow one + * (errors.M2). */ + @state() + private loadError = ''; + private scanCompleteCleanup: (() => void) | null = null; @@ -38,6 +45,21 @@ export class GenreDetails extends LitElement { height: 100%; } + .load-error { + color: var(--yj-text-secondary, #b3b3b3); + padding: 1em; + } + + .load-error button { + background: none; + border: 1px solid var(--yj-border, #495057); + border-radius: 4px; + color: inherit; + cursor: pointer; + font: inherit; + padding: 4px 10px; + } + /* ==================================== * Header * ==================================== */ @@ -179,6 +201,8 @@ export class GenreDetails extends LitElement { private async loadTracks() { if (!this.genreName) return; + this.loadError = ''; + try { const libId = libraryStore.getSelectedLibraryId(); @@ -192,11 +216,12 @@ export class GenreDetails extends LitElement { this.genreName, ); } catch (error) { - console.error( - 'Error loading genre tracks:', - error, - ); + console.error('Error loading genre tracks:', error); this.tracks = []; + this.loadError = describeError( + error, + `The tracks for “${this.genreName}” could not be loaded.`, + ); } finally { this.loading = false; } @@ -274,9 +299,19 @@ export class GenreDetails extends LitElement {
- + ${this.loadError + ? html`
+

${this.loadError}

+ +
` + : html``}
`; } diff --git a/frontend/src/components/home-view/home-view.ts b/frontend/src/components/home-view/home-view.ts index d18749f..3cd9b74 100644 --- a/frontend/src/components/home-view/home-view.ts +++ b/frontend/src/components/home-view/home-view.ts @@ -10,6 +10,7 @@ import { libraryStore } from '@store/library-store'; import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; import { designTokens } from '../../styles/tokens.css'; +import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; type Shelf = home.Shelf; @@ -36,7 +37,7 @@ const KIND_ICONS: Record = { * cover do the two things a cover should: open the album, or play it. */ @customElement('home-view') -export class HomeView extends LitElement { +export class HomeView extends ViewLifecycleMixin(LitElement) { @state() private shelves: Shelf[] = []; @state() private loading = true; @@ -194,8 +195,10 @@ export class HomeView extends LitElement { `, ]; - override connectedCallback(): void { - super.connectedCallback(); + 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 @@ -204,12 +207,10 @@ export class HomeView extends LitElement { this.unsubScan = EventsOn(Events.LibraryScanComplete, () => { void this.load(); }); - } - - override disconnectedCallback(): void { - super.disconnectedCallback(); - this.unsubScan?.(); - this.unsubScan = undefined; + this.whileActive(() => { + this.unsubScan?.(); + this.unsubScan = undefined; + }); } /** diff --git a/frontend/src/components/jobs/job-controls.ts b/frontend/src/components/jobs/job-controls.ts index 8b94368..a1fdd01 100644 --- a/frontend/src/components/jobs/job-controls.ts +++ b/frontend/src/components/jobs/job-controls.ts @@ -1,4 +1,8 @@ import { jobStore } from '@store/job-store'; +import { notificationStore } from '@store/notification-store'; +import { describeError } from '@utils/describe-error'; + +import { confirmAction } from '../confirm-dialog/confirm-dialog'; export type JobControlAction = 'pause' | 'resume' | 'cancel' | 'dismiss'; @@ -7,31 +11,57 @@ export interface JobControlDetail { action: JobControlAction; } +const VERB: Record = { + pause: 'pause', + resume: 'resume', + cancel: 'cancel', + dismiss: 'dismiss', +}; + /** * Applies a `job-control` event emitted by a ``. * * Shared by every host that renders job rows — the top-bar popover, the * jobs page, and the details drawer — so a control behaves identically * wherever it is pressed, and so no host can forget to wire one up. + * + * This is used directly as a DOM listener, so its promise is discarded: + * a rejection has to be caught *here* or it is an unhandled rejection + * and a button that silently does nothing (errors.M4). */ export async function applyJobControl(e: Event): Promise { const { id, action } = (e as CustomEvent).detail as JobControlDetail; - if (action === 'cancel' && !confirmCancel(id)) return; + if (action === 'cancel' && !(await confirmCancel(id))) return; - switch (action) { - case 'pause': - await jobStore.pause(id); - break; - case 'resume': - await jobStore.resume(id); - break; - case 'cancel': - await jobStore.cancel(id); - break; - case 'dismiss': - await jobStore.dismiss(id); - break; + try { + switch (action) { + case 'pause': + await jobStore.pause(id); + break; + case 'resume': + await jobStore.resume(id); + break; + case 'cancel': + await jobStore.cancel(id); + break; + case 'dismiss': + await jobStore.dismiss(id); + break; + } + } catch (err) { + console.error(`job ${action} failed`, err); + + const job = jobStore.getJob(id); + const name = job?.title ?? 'that job'; + + // Transient: the button visibly did not take, and the next + // JobsChanged snapshot says what is actually true. + notificationStore.transient({ + key: `job-${action}`, + text: `Could not ${VERB[action]} ${name}. ${describeError(err)}`, + detail: String(err), + }); } } @@ -40,15 +70,20 @@ export async function applyJobControl(e: Event): Promise { * so it is worth a confirmation even though the checkpoint survives. A * library scan is cheap to re-run — don't nag for that one. */ -function confirmCancel(id: string): boolean { +function confirmCancel(id: string): Promise { const job = jobStore.getJob(id); - if (job?.kind !== 'index-build') return true; + if (job?.kind !== 'index-build') return Promise.resolve(true); - return window.confirm( - 'Stop building the search index?\n\n' + + return confirmAction({ + title: 'Stop building the search index?', + message: 'Progress is checkpointed, so you can resume later without ' + - 're-downloading. Until it finishes, search results stay ' + - 'limited to your own library.', - ); + 're-downloading.', + impact: + 'Until it finishes, search results stay limited to your own ' + + 'library.', + confirmLabel: 'Stop building', + danger: true, + }); } diff --git a/frontend/src/components/jobs/job-format.ts b/frontend/src/components/jobs/job-format.ts index 1bacd9f..10d5bd7 100644 --- a/frontend/src/components/jobs/job-format.ts +++ b/frontend/src/components/jobs/job-format.ts @@ -9,6 +9,8 @@ export function jobIcon(job: Job): string { return 'folder'; case 'index-build': return 'database'; + case 'autotag-apply': + return 'tags'; default: return 'gear'; } diff --git a/frontend/src/components/jobs/jobs-view.ts b/frontend/src/components/jobs/jobs-view.ts index ee4b692..e19eac5 100644 --- a/frontend/src/components/jobs/jobs-view.ts +++ b/frontend/src/components/jobs/jobs-view.ts @@ -13,10 +13,14 @@ import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; import { jobStore } from '@store/job-store'; import type { Job } from '@store/job-store'; +import { notificationStore } from '@store/notification-store'; +import { describeError } from '@utils/describe-error'; +import { confirmAction } from '../confirm-dialog/confirm-dialog'; import './job-row'; import './job-details-drawer'; import { applyJobControl } from './job-controls'; import { jobStateStyles } from './job-format'; +import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; type LibraryInfo = library.Info; @@ -36,7 +40,7 @@ const TERMINAL_STATES: ReadonlySet = new Set([ * one implementation, two placements, so the two can never disagree. */ @customElement('jobs-view') -export class JobsView extends LitElement { +export class JobsView extends ViewLifecycleMixin(LitElement) { @state() private jobs: Job[] = []; @@ -49,6 +53,15 @@ export class JobsView extends LitElement { @state() private drawerOpen = false; + /** + * Set between pressing a scan button and the job snapshot that + * proves it started. `anyScanning` is derived from `JobsChanged`, + * which is coalesced at 250 ms — long enough for a second click to + * start a second scan (errors.M5). + */ + @state() + private starting = false; + private unsubscribe: (() => void) | null = null; private eventCleanups: Array<() => void> = []; @@ -221,8 +234,7 @@ export class JobsView extends LitElement { `, ]; - override connectedCallback(): void { - super.connectedCallback(); + protected override onViewActivate(): void { this.unsubscribe = jobStore.subscribe(() => { this.jobs = jobStore.jobs; }); @@ -243,8 +255,7 @@ export class JobsView extends LitElement { } } - override disconnectedCallback(): void { - super.disconnectedCallback(); + protected override onViewDeactivate(): void { this.unsubscribe?.(); this.unsubscribe = null; this.eventCleanups.forEach((off) => off()); @@ -273,20 +284,52 @@ export class JobsView extends LitElement { this.drawerOpen = false; }; - private async startScan(id: number) { + /** + * Run something that starts a job, holding the buttons until the + * snapshot lands and saying so when it does not start at all. + * + * Persistent, not a toast: the user asked for work to happen, it + * did not, and retrying is exactly the useful response. + */ + private async startJob( + what: string, + start: () => Promise, + retry: () => void, + ): Promise { + if (this.starting) return; + + this.starting = true; + try { - await ScanLibrary(id); + await start(); } catch (err) { - console.error('Failed to start scan:', err); + console.error(`${what} failed:`, err); + notificationStore.persistent({ + key: 'scan-start', + title: 'Scan did not start', + text: `${what} failed. ${describeError(err)}`, + detail: String(err), + action: { label: 'Try again', run: retry }, + }); + } finally { + this.starting = false; } } + private async startScan(id: number) { + await this.startJob( + 'Scanning that library', + () => ScanLibrary(id), + () => void this.startScan(id), + ); + } + private async startAllScans() { - try { - await ScanAllLibraries(); - } catch (err) { - console.error('Failed to start scans:', err); - } + await this.startJob( + 'Scanning your libraries', + () => ScanAllLibraries(), + () => void this.startAllScans(), + ); } private async clearFinished() { @@ -294,22 +337,25 @@ export class JobsView extends LitElement { } private async fullRescan() { - if ( - !window.confirm( - 'Full rescan deletes ALL library data — including ' + - 'downloaded cover art — and rebuilds it from your ' + - 'files.\n\nThis is not the same as "Scan now", which ' + - 'only picks up what changed. Continue?', - ) - ) { - return; - } + const ok = await confirmAction({ + title: 'Full rescan', + message: + 'This deletes all library data — including downloaded ' + + 'cover art — and rebuilds it from your files.', + impact: + 'It is not the same as “Scan now”, which only picks up ' + + 'what changed.', + confirmLabel: 'Rebuild everything', + danger: true, + }); - try { - await FullRescan(); - } catch (err) { - console.error('Full rescan failed:', err); - } + if (!ok) return; + + await this.startJob( + 'The full rescan', + () => FullRescan(), + () => void this.fullRescan(), + ); } private renderJobList(list: Job[], emptyText: string) { @@ -393,6 +439,7 @@ export class JobsView extends LitElement { : html`