feat(12-02): selectable library list with checkbox scan targeting

- Add checkboxes to library list with select-all header
- Soft Scan operates on selected libraries (queues each individually)
- Full Rescan stays global (nukes all data, rescans all libraries)
- Remove redundant 'Scan All Libraries' button
- Fix FullRescan Go backend to scan all libraries after wipe, not just first
This commit is contained in:
2026-03-13 10:33:15 -04:00
parent b36e472212
commit 13a42aea22
2 changed files with 177 additions and 93 deletions
+15 -10
View File
@@ -16,14 +16,13 @@ var errNoLibrariesConfigured = errors.New(
// FullRescan clears the queue and player, wipes all library data // FullRescan clears the queue and player, wipes all library data
// (database records and cover art files), and performs a fresh // (database records and cover art files), and performs a fresh
// scan of the first library from the database. Per-library full // scan of every library in the database. The returned ScanMetrics
// rescan will be added in Phase 12; for now this rescans the // reflects the last library scanned; clear-phase durations are
// first/only library. The returned ScanMetrics includes timing // folded into its totals.
// for the clear phases in addition to the normal scan metrics.
func (l *Library) FullRescan() (*ScanMetrics, error) { func (l *Library) FullRescan() (*ScanMetrics, error) {
l.logger.Info("beginning full library rescan") l.logger.Info("beginning full library rescan")
// Resolve the first library from the database. // Resolve all libraries from the database.
libs, err := l.db.Queries.GetAllLibraries(l.ctx) libs, err := l.db.Queries.GetAllLibraries(l.ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -35,8 +34,6 @@ func (l *Library) FullRescan() (*ScanMetrics, error) {
return nil, errNoLibrariesConfigured return nil, errNoLibrariesConfigured
} }
lib := libs[0]
// Run the pre-clear hook (e.g. clear queue / stop playback) // Run the pre-clear hook (e.g. clear queue / stop playback)
// before wiping data so the player is not referencing // before wiping data so the player is not referencing
// now-deleted tracks. // now-deleted tracks.
@@ -71,9 +68,17 @@ func (l *Library) FullRescan() (*ScanMetrics, error) {
l.logger.Info("library data cleared successfully") l.logger.Info("library data cleared successfully")
// Scan the first library directly via scanInternal, // Scan the first library directly (bypassing the queue) so
// bypassing the queue coordinator. // we get ScanMetrics back. Queue remaining libraries so
metrics := l.scanInternal(lib.ID, lib.Name, lib.Path) // they run sequentially via the scan coordinator.
metrics := l.scanInternal(libs[0].ID, libs[0].Name, libs[0].Path)
for _, lib := range libs[1:] {
if scanErr := l.ScanLibrary(lib.ID); scanErr != nil {
l.logger.Error("could not queue library for rescan",
"libraryID", lib.ID, "err", scanErr)
}
}
if metrics != nil { if metrics != nil {
metrics.ClearQueue = clearQueueDur metrics.ClearQueue = clearQueueDur
@@ -350,6 +350,7 @@ export class ConfigPage extends LitElement {
@state() private toastMessage = ''; @state() private toastMessage = '';
@state() private toastVisible = false; @state() private toastVisible = false;
@state() private activeMenuId: number | null = null; @state() private activeMenuId: number | null = null;
@state() private selectedLibraryIds: Set<number> = new Set();
@state() private scanning = false; @state() private scanning = false;
@state() private statusMessage = ''; @state() private statusMessage = '';
@state() private scanProgress: ScanProgress | null = null; @state() private scanProgress: ScanProgress | null = null;
@@ -834,6 +835,25 @@ export class ConfigPage extends LitElement {
gap: 0; gap: 0;
} }
.library-header {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.4em 0.5em;
border-bottom: 1px solid var(--yj-border-subtle, #333);
font-size: var(--yj-text-sm, 0.8em);
color: var(--yj-text-muted, #999);
}
.library-header-label {
user-select: none;
}
.library-checkbox {
cursor: pointer;
flex-shrink: 0;
}
.library-row { .library-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1152,6 +1172,18 @@ export class ConfigPage extends LitElement {
this.libraries = libs ?? []; this.libraries = libs ?? [];
this.concurrencyMode = mode; this.concurrencyMode = mode;
// Auto-select all libraries on initial load so scan
// buttons default to operating on everything.
if (this.selectedLibraryIds.size === 0 && this.libraries.length > 0) {
this.selectedLibraryIds = new Set(this.libraries.map((l) => l.id));
} else {
// Prune selections for libraries that no longer exist.
const validIds = new Set(this.libraries.map((l) => l.id));
const pruned = new Set([...this.selectedLibraryIds].filter((id) => validIds.has(id)));
this.selectedLibraryIds = pruned;
}
} catch (err) { } catch (err) {
console.error( console.error(
'Failed to load libraries:', 'Failed to load libraries:',
@@ -1381,6 +1413,26 @@ export class ConfigPage extends LitElement {
this.activeMenuId = this.activeMenuId === id ? null : id; this.activeMenuId = this.activeMenuId === id ? null : id;
}; };
private toggleLibrarySelection = (id: number): void => {
const next = new Set(this.selectedLibraryIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
this.selectedLibraryIds = next;
};
private toggleSelectAllLibraries = (): void => {
if (this.selectedLibraryIds.size === this.libraries.length) {
this.selectedLibraryIds = new Set();
} else {
this.selectedLibraryIds = new Set(this.libraries.map((l) => l.id));
}
};
private handleDocumentClick = (): void => { private handleDocumentClick = (): void => {
if (this.activeMenuId !== null) { if (this.activeMenuId !== null) {
this.activeMenuId = null; this.activeMenuId = null;
@@ -1419,19 +1471,19 @@ export class ConfigPage extends LitElement {
}); });
}; };
private handleScanAll = async (): Promise<void> => {
try {
await ScanAllLibraries();
} catch (err) {
this.statusMessage =
'Scan all libraries completed with errors.';
this.scanErrors = String(err);
}
};
private handleSoftScan = async (): Promise<void> => { private handleSoftScan = async (): Promise<void> => {
try { try {
await ScanAllLibraries(); if (this.selectedLibraryIds.size === 0) return;
// If all libraries selected, use the batch method.
if (this.selectedLibraryIds.size === this.libraries.length) {
await ScanAllLibraries();
} else {
// Queue each selected library individually.
for (const id of this.selectedLibraryIds) {
await ScanLibrary(id);
}
}
} catch (err) { } catch (err) {
this.statusMessage = this.statusMessage =
'Scan completed with errors.'; 'Scan completed with errors.';
@@ -2198,77 +2250,107 @@ export class ConfigPage extends LitElement {
(l) => l.id === this.removingLibraryId, (l) => l.id === this.removingLibraryId,
); );
const allSelected = this.libraries.length > 0
&& this.selectedLibraryIds.size === this.libraries.length;
const someSelected = this.selectedLibraryIds.size > 0
&& !allSelected;
const selectionCount = this.selectedLibraryIds.size;
return html` return html`
<config-section <config-section
heading="Libraries" heading="Libraries"
description="Manage your music library folders. Each library is scanned independently." description="Manage your music library folders. Select libraries to scan."
> >
<div class="library-list"> ${this.libraries.length > 0
${this.libraries.map( ? html`
(lib) => html` <div class="library-list">
<div class="library-row"> <div class="library-header">
${this.editingLibraryId === lib.id <input
? html` type="checkbox"
<input class="library-checkbox"
class="edit-input" .checked=${allSelected}
type="text" .indeterminate=${someSelected}
.value=${this.editingName} @change=${this.toggleSelectAllLibraries}
@input=${this.handleRenameInput} @click=${(e: Event) => e.stopPropagation()}
@keydown=${this.handleRenameKeyDown} />
@click=${(e: Event) => e.stopPropagation()} <span class="library-header-label">
/> ${allSelected ? 'All' : someSelected ? `${selectionCount}` : 'None'} selected
`
: html`
<span
class="library-name"
@click=${() => this.handleStartRename(lib.id, lib.name)}
>
${lib.name}
</span>
`}
<span class="library-path">${lib.path}</span>
<span class="library-count">
${lib.trackCount} tracks
</span> </span>
<div class="overflow-wrapper"> </div>
<button ${this.libraries.map(
class="overflow-btn" (lib) => html`
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)} <div class="library-row">
> <input
\u22EF type="checkbox"
</button> class="library-checkbox"
${this.activeMenuId === lib.id .checked=${this.selectedLibraryIds.has(lib.id)}
? html` @change=${() => this.toggleLibrarySelection(lib.id)}
<div @click=${(e: Event) => e.stopPropagation()}
class="overflow-menu" />
@click=${(e: Event) => e.stopPropagation()} ${this.editingLibraryId === lib.id
> ? html`
<div <input
class="overflow-item" class="edit-input"
type="text"
.value=${this.editingName}
@input=${this.handleRenameInput}
@keydown=${this.handleRenameKeyDown}
@click=${(e: Event) => e.stopPropagation()}
/>
`
: html`
<span
class="library-name"
@click=${() => this.handleStartRename(lib.id, lib.name)} @click=${() => this.handleStartRename(lib.id, lib.name)}
> >
Rename ${lib.name}
</div> </span>
<div `}
class="overflow-item" <span class="library-path">${lib.path}</span>
@click=${() => this.handleRescanLibrary(lib.id)} <span class="library-count">
> ${lib.trackCount} tracks
Rescan </span>
</div> <div class="overflow-wrapper">
<div <button
class="overflow-item overflow-item--danger" class="overflow-btn"
@click=${() => void this.handleRemoveClick(lib.id)} @click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
> >
Remove \u22EF
</div> </button>
</div> ${this.activeMenuId === lib.id
` ? html`
: nothing} <div
</div> class="overflow-menu"
</div> @click=${(e: Event) => e.stopPropagation()}
`, >
)} <div
</div> class="overflow-item"
@click=${() => this.handleStartRename(lib.id, lib.name)}
>
Rename
</div>
<div
class="overflow-item"
@click=${() => this.handleRescanLibrary(lib.id)}
>
Rescan
</div>
<div
class="overflow-item overflow-item--danger"
@click=${() => void this.handleRemoveClick(lib.id)}
>
Remove
</div>
</div>
`
: nothing}
</div>
</div>
`,
)}
</div>
`
: nothing}
<button <button
class="btn-primary add-library-btn" class="btn-primary add-library-btn"
@@ -2328,10 +2410,13 @@ export class ConfigPage extends LitElement {
` `
: html` : html`
<button <button
class="btn-warning" class="btn-primary"
@click=${this.handleSoftScan} @click=${this.handleSoftScan}
?disabled=${selectionCount === 0}
> >
Soft Scan Scan${selectionCount > 0 && selectionCount < this.libraries.length
? ` (${selectionCount})`
: ''}
</button> </button>
<button <button
class="btn-danger" class="btn-danger"
@@ -2339,12 +2424,6 @@ export class ConfigPage extends LitElement {
> >
Full Rescan Full Rescan
</button> </button>
<button
class="btn-primary"
@click=${this.handleScanAll}
>
Scan All Libraries
</button>
`} `}
</div> </div>