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
// (database records and cover art files), and performs a fresh
// scan of the first library from the database. Per-library full
// rescan will be added in Phase 12; for now this rescans the
// first/only library. The returned ScanMetrics includes timing
// for the clear phases in addition to the normal scan metrics.
// scan of every library in the database. The returned ScanMetrics
// reflects the last library scanned; clear-phase durations are
// folded into its totals.
func (l *Library) FullRescan() (*ScanMetrics, error) {
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)
if err != nil {
return nil, fmt.Errorf(
@@ -35,8 +34,6 @@ func (l *Library) FullRescan() (*ScanMetrics, error) {
return nil, errNoLibrariesConfigured
}
lib := libs[0]
// Run the pre-clear hook (e.g. clear queue / stop playback)
// before wiping data so the player is not referencing
// now-deleted tracks.
@@ -71,9 +68,17 @@ func (l *Library) FullRescan() (*ScanMetrics, error) {
l.logger.Info("library data cleared successfully")
// Scan the first library directly via scanInternal,
// bypassing the queue coordinator.
metrics := l.scanInternal(lib.ID, lib.Name, lib.Path)
// Scan the first library directly (bypassing the queue) so
// we get ScanMetrics back. Queue remaining libraries so
// 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 {
metrics.ClearQueue = clearQueueDur
@@ -350,6 +350,7 @@ export class ConfigPage extends LitElement {
@state() private toastMessage = '';
@state() private toastVisible = false;
@state() private activeMenuId: number | null = null;
@state() private selectedLibraryIds: Set<number> = new Set();
@state() private scanning = false;
@state() private statusMessage = '';
@state() private scanProgress: ScanProgress | null = null;
@@ -834,6 +835,25 @@ export class ConfigPage extends LitElement {
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 {
display: flex;
align-items: center;
@@ -1152,6 +1172,18 @@ export class ConfigPage extends LitElement {
this.libraries = libs ?? [];
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) {
console.error(
'Failed to load libraries:',
@@ -1381,6 +1413,26 @@ export class ConfigPage extends LitElement {
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 => {
if (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> => {
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) {
this.statusMessage =
'Scan completed with errors.';
@@ -2198,77 +2250,107 @@ export class ConfigPage extends LitElement {
(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`
<config-section
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.map(
(lib) => html`
<div class="library-row">
${this.editingLibraryId === lib.id
? html`
<input
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)}
>
${lib.name}
</span>
`}
<span class="library-path">${lib.path}</span>
<span class="library-count">
${lib.trackCount} tracks
${this.libraries.length > 0
? html`
<div class="library-list">
<div class="library-header">
<input
type="checkbox"
class="library-checkbox"
.checked=${allSelected}
.indeterminate=${someSelected}
@change=${this.toggleSelectAllLibraries}
@click=${(e: Event) => e.stopPropagation()}
/>
<span class="library-header-label">
${allSelected ? 'All' : someSelected ? `${selectionCount}` : 'None'} selected
</span>
<div class="overflow-wrapper">
<button
class="overflow-btn"
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
>
\u22EF
</button>
${this.activeMenuId === lib.id
? html`
<div
class="overflow-menu"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="overflow-item"
</div>
${this.libraries.map(
(lib) => html`
<div class="library-row">
<input
type="checkbox"
class="library-checkbox"
.checked=${this.selectedLibraryIds.has(lib.id)}
@change=${() => this.toggleLibrarySelection(lib.id)}
@click=${(e: Event) => e.stopPropagation()}
/>
${this.editingLibraryId === lib.id
? html`
<input
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)}
>
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>
${lib.name}
</span>
`}
<span class="library-path">${lib.path}</span>
<span class="library-count">
${lib.trackCount} tracks
</span>
<div class="overflow-wrapper">
<button
class="overflow-btn"
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
>
\u22EF
</button>
${this.activeMenuId === lib.id
? html`
<div
class="overflow-menu"
@click=${(e: Event) => e.stopPropagation()}
>
<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
class="btn-primary add-library-btn"
@@ -2328,10 +2410,13 @@ export class ConfigPage extends LitElement {
`
: html`
<button
class="btn-warning"
class="btn-primary"
@click=${this.handleSoftScan}
?disabled=${selectionCount === 0}
>
Soft Scan
Scan${selectionCount > 0 && selectionCount < this.libraries.length
? ` (${selectionCount})`
: ''}
</button>
<button
class="btn-danger"
@@ -2339,12 +2424,6 @@ export class ConfigPage extends LitElement {
>
Full Rescan
</button>
<button
class="btn-primary"
@click=${this.handleScanAll}
>
Scan All Libraries
</button>
`}
</div>