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:
+15
-10
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user