diff --git a/frontend/src/components/autotag-view/autotag-view.ts b/frontend/src/components/autotag-view/autotag-view.ts
index 6d4f6e6..56e2fc4 100644
--- a/frontend/src/components/autotag-view/autotag-view.ts
+++ b/frontend/src/components/autotag-view/autotag-view.ts
@@ -29,6 +29,7 @@ import { nameDialogsIn } from '../../utils/name-dialog';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
+import '@components/jobs/job-panel';
import { list } from '@utils/binding';
type PendingItem = autotagservice.PendingItem;
@@ -135,8 +136,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
padding: 0.75rem 1rem;
}
- .header {
+ /* The header and the apply-job panel share the header row.
+ A wrapper rather than a third grid row, because the panel
+ is display:none while nothing is applying and a grid
+ row would still spend the container's gap on it -- the
+ idle case, which is nearly always. */
+ .header-area {
grid-area: header;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-width: 0;
+ }
+
+ .header {
display: flex;
align-items: center;
gap: 0.75rem;
@@ -3207,7 +3220,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
// sees a blank full-screen "Loading\u2026".
return html`
- ${this.renderHeader()}
+
${this.renderFolderSidebar()}
${this.renderMain()}
diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts
index 70078fb..9d9236b 100644
--- a/frontend/src/components/config-page/config-page.ts
+++ b/frontend/src/components/config-page/config-page.ts
@@ -9,7 +9,13 @@ import {
RemoveLibrary,
GetRemovalImpact,
GetAllLibrariesWithTrackCounts,
+ ScanLibrary,
+ ScanAllLibraries,
+ FullRescan,
} from '@go/library/library.js';
+import { jobStore } from '@store/job-store';
+import type { Job } from '@store/job-store';
+import '@components/jobs/job-panel';
import {
GetScanConcurrency,
SetScanConcurrency,
@@ -77,6 +83,24 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
/** Which destinations the navigation offers (#25). */
private viewsCtrl = new ViewVisibilityController(this);
+ /**
+ * The job snapshot, for the per-library scan status (#27).
+ *
+ * Held as state rather than read from the store in `render()` so
+ * Lit sees the dependency: the store notifies, and a getter read
+ * inside a template is not a reactive input.
+ */
+ @state() private jobs: Job[] = [];
+
+ /**
+ * Set between pressing a scan button and the job snapshot that
+ * proves it started -- `JobsChanged` is coalesced at 250 ms, which
+ * is long enough for a second click to start a second scan.
+ */
+ @state() private startingScan = false;
+
+ private unsubscribeJobs: (() => void) | null = null;
+
// --- Shortcuts controller ---
private shortcutsCtrl = new ShortcutsController(this);
@@ -864,8 +888,14 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
this.scrollMode =
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
- // Scan progress lives in the jobs panel now — this page only
- // needs to know when the library list itself changes.
+ // Scanning is started and watched here (#27), so the job
+ // snapshot is a live input to this page.
+ this.unsubscribeJobs = jobStore.subscribe(() => {
+ this.jobs = jobStore.jobs;
+ });
+ this.jobs = jobStore.jobs;
+ void jobStore.init();
+
this.cancelLibraryAdded = EventsOn(
Events.LibraryAdded,
() => void this.loadLibraries(),
@@ -896,6 +926,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
}
protected override onViewDeactivate(): void {
+ this.unsubscribeJobs?.();
+ this.unsubscribeJobs = null;
+
this.cancelLibraryAdded?.();
this.cancelLibraryRenamed?.();
this.cancelLibraryRemoved?.();
@@ -1096,6 +1129,114 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
}
}
+ // ===================================================================
+ // SCANNING (#27 — back from the Jobs tab)
+ // ===================================================================
+
+ /** The scan job for a library, if one is registered. */
+ private jobForLibrary(id: number): Job | undefined {
+ return this.jobs.find((job) => job.id === `scan:${id}`);
+ }
+
+ /** The status line under a library name while it is being scanned. */
+ private libraryScanStatus(id: number): string | null {
+ const job = this.jobForLibrary(id);
+
+ if (!job) return null;
+
+ switch (job.state) {
+ case 'running':
+ return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
+ case 'queued':
+ return 'Queued';
+ case 'paused':
+ return 'Paused';
+ case 'pausing':
+ return 'Pausing…';
+ case 'cancelling':
+ return 'Stopping…';
+ default:
+ return null;
+ }
+ }
+
+ private get anyScanning(): boolean {
+ return this.libraries.some(
+ (lib) => this.libraryScanStatus(lib.id) !== null,
+ );
+ }
+
+ /**
+ * 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.startingScan) return;
+
+ this.startingScan = true;
+
+ try {
+ await start();
+ } catch (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.startingScan = false;
+ }
+ }
+
+ private handleScanLibrary = (id: number): void => {
+ this.activeMenuId = null;
+ void this.startJob(
+ 'Scanning that library',
+ () => ScanLibrary(id),
+ () => this.handleScanLibrary(id),
+ );
+ };
+
+ private handleScanAll = (): void => {
+ void this.startJob(
+ 'Scanning your libraries',
+ () => ScanAllLibraries(),
+ () => this.handleScanAll(),
+ );
+ };
+
+ private handleFullRescan = async (): Promise => {
+ 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,
+ });
+
+ if (!ok) return;
+
+ await this.startJob(
+ 'The full rescan',
+ () => FullRescan(),
+ () => void this.handleFullRescan(),
+ );
+ };
+
private handleViewToggle = (
view: string,
visible: boolean,
@@ -1554,6 +1695,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.value=${this.allowMeteredCatalogDownload}
@config-change=${this.handleAllowMeteredChange}
>
+
+
+
`;
}
@@ -1674,18 +1829,15 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
description:
'The page the app opens to on launch.',
type: 'select' as const,
- options: [
- { value: 'home', label: 'Home' },
- { value: 'tracks', label: 'Tracks' },
- { value: 'albums', label: 'Albums' },
- { value: 'artists', label: 'Artists' },
- { value: 'genres', label: 'Genres' },
- { value: 'playlists', label: 'Playlists' },
- { value: 'explore', label: 'Explore' },
- { value: 'downloads', label: 'Downloads' },
- { value: 'autotag', label: 'Autotag' },
- { value: 'jobs', label: 'Jobs' },
- ],
+ // Derived, not written out: this list was a
+ // second copy of the launchable set, and #27
+ // removing a destination is exactly the change
+ // that would have left the two disagreeing.
+ // Settings is excluded because it is the one
+ // view the backend refuses to launch into.
+ options: VIEW_META
+ .filter((v) => v.alwaysShown !== true)
+ .map((v) => ({ value: v.id, label: v.label })),
}}
.value=${this.defaultPage}
@config-change=${this.handleDefaultPageChange}
@@ -2161,8 +2313,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
return html`
@@ -2172,6 +2324,23 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
>
Add Library
+
+ Scan All
+
+
+ Full Rescan
+
${this.libraries.length > 0
@@ -2209,7 +2378,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
${this.removingLibraryId === lib.id
? 'Removing…'
- : html`${lib.trackCount} tracks`}
+ : this.libraryScanStatus(lib.id)
+ ?? html`${lib.trackCount} tracks`}
Rename
+ ${this.libraryScanStatus(lib.id) === null
+ ? html`
+ this.handleScanLibrary(lib.id)}
+ >
+ Scan now
+
+ `
+ : nothing}
void this.handleRemoveClick(lib.id)}
@@ -2275,6 +2455,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@config-change=${this.handleConcurrencyChange}
>
+
+
`;
}
diff --git a/frontend/src/components/config-page/download-clients.ts b/frontend/src/components/config-page/download-clients.ts
index 0fb7052..6aef7a3 100644
--- a/frontend/src/components/config-page/download-clients.ts
+++ b/frontend/src/components/config-page/download-clients.ts
@@ -22,6 +22,7 @@ import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section';
+import '@components/jobs/job-panel';
import { pickDirectory } from '../../utils/pick-directory';
/**
@@ -274,6 +275,17 @@ export class DownloadClients extends LitElement {
`}
+
+
+
Found a clear match and started downloading it. Progress is
- in the background jobs panel.
+ on the Downloads page.
`;
}