From 12af6ec1f74101fca5ab0bcbb1849fcf376d2344 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 20:27:33 -0400 Subject: [PATCH] feat(settings): scan from Settings, and watch every job in place Scanning goes back where libraries are managed -- the Jobs tab's own comment said the per-library controls had been taken out of Settings to build it. "Scan All" and "Full Rescan" join "Add Library", "Scan now" joins the per-library overflow menu beside Rename and Remove, and a library being scanned says so where its track count goes. Each surface then gets the job rows for its own kind: scans under Libraries, index and enrichment under Search Index, downloads under the download clients, and the autotag apply in the Autotag view -- where stopping a run matters most, since applying rewrites tags on disk and that view had no cancel at all. The autotag panel shares the header's grid row through a wrapper rather than taking a third row: it is display:none while nothing is applying, which is nearly always, and a grid row would still spend the container's gap on it. The Launch Page select is derived from `VIEW_META` instead of listing its ten options, since removing a destination is exactly the change that leaves two hand-written copies disagreeing. --- .../components/autotag-view/autotag-view.ts | 30 ++- .../src/components/config-page/config-page.ts | 219 ++++++++++++++++-- .../config-page/download-clients.ts | 12 + .../download-picker/download-picker.ts | 2 +- 4 files changed, 243 insertions(+), 20 deletions(-) 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.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 + +
${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`}
+ ${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. `; }