feat: add scan progress bar with phase indicator

Add live progress reporting during library scans:

- Pre-walk count: fast WalkDir to count audio files upfront for
  percentage calculation (~1-2s overhead)
- Progress ticker: emits ScanProgress events every 300ms with
  phase, file counts (added/skipped/updated), and total
- Phase labels: counting → scanning → thumbnails → orphans
- Frontend: progress bar with percentage, file counts breakdown,
  and phase indicator in both config-page and library-manager

Replaces the static 'Scanning...' text with a live progress bar
showing e.g. '62% — Scanning... 1,247 / 2,013 files (891 new,
356 skipped)'
This commit is contained in:
2026-03-05 11:21:53 -05:00
parent a2975531f4
commit a28b4d1e06
6 changed files with 407 additions and 6 deletions
+1
View File
@@ -43,5 +43,6 @@ const (
// Library events.
const (
LibraryScanStarted = "LibraryScanStarted"
LibraryScanProgress = "LibraryScanProgress"
LibraryScanComplete = "LibraryScanComplete"
)
+107 -2
View File
@@ -193,6 +193,20 @@ func (l *Library) Scan() (*ScanMetrics, error) {
runtime.EventsEmit(l.ctx, events.LibraryScanStarted)
basePath := string(l.conf.DirectoryPath)
// --- Pre-walk: count audio files for progress reporting ---
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
ScanProgress{Phase: "counting"},
)
totalFiles := countAudioFiles(basePath)
l.logger.Debug(
"pre-walk file count complete",
"total", totalFiles,
)
// --- Phase 1: load existing files from DB ---
loadStart := time.Now()
@@ -215,8 +229,6 @@ func (l *Library) Scan() (*ScanMetrics, error) {
"count", len(existingFiles),
"library-directory", l.conf.DirectoryPath,
)
basePath := string(l.conf.DirectoryPath)
workChan := make(chan scanWork, 100)
resultChan := make(chan importResult, 100)
@@ -359,6 +371,41 @@ func (l *Library) Scan() (*ScanMetrics, error) {
}()
}
// --- Progress ticker ---
// Periodically emits scan progress to the frontend. Stopped
// when the main scan phases (walk + extraction + DB writes)
// are complete, before orphan cleanup begins.
stopProgress := make(chan struct{})
go func() {
ticker := time.NewTicker(progressInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a := added.Load()
s := skipped.Load()
u := updated.Load()
runtime.EventsEmit(
l.ctx,
events.LibraryScanProgress,
ScanProgress{
Phase: "scanning",
Total: totalFiles,
Processed: a + s + u,
Added: a,
Skipped: s,
Updated: u,
},
)
case <-stopProgress:
return
}
}
}()
// --- Phase 4: DB writer goroutine ---
var dbWg sync.WaitGroup
@@ -460,17 +507,46 @@ func (l *Library) Scan() (*ScanMetrics, error) {
close(resultChan)
dbWg.Wait()
// Stop the progress ticker — main scan phases are done.
close(stopProgress)
// Emit a final "scanning" progress so the bar reaches 100%.
a := added.Load()
s := skipped.Load()
u := updated.Load()
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
ScanProgress{
Phase: "scanning",
Total: totalFiles,
Processed: a + s + u,
Added: a,
Skipped: s,
Updated: u,
},
)
// Close thumbnail channel and wait for all thumbnail workers
// to finish. The DB writer has stopped sending work at this
// point so it is safe to close.
thumbStart := time.Now()
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
ScanProgress{Phase: "thumbnails", Total: totalFiles,
Processed: a + s + u, Added: a, Skipped: s, Updated: u},
)
close(thumbChan)
thumbWg.Wait()
metrics.ThumbnailWallClock = time.Since(thumbStart)
// --- Phase 5: orphan cleanup ---
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
ScanProgress{Phase: "orphans", Total: totalFiles,
Processed: a + s + u, Added: a, Skipped: s, Updated: u},
)
orphanStart := time.Now()
var removed atomic.Int64
@@ -557,6 +633,35 @@ func (l *Library) Scan() (*ScanMetrics, error) {
return metrics, scanErr
}
// progressInterval controls how often scan progress events are
// emitted to the frontend.
const progressInterval = 300 * time.Millisecond
// countAudioFiles performs a fast walk of the library directory,
// counting only files with supported audio extensions. No per-file
// I/O is performed — this reads only directory entries.
func countAudioFiles(basePath string) int64 {
var count int64
_ = fs.WalkDir(
os.DirFS(basePath), ".",
func(_ string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := filepath.Ext(d.Name())
if _, ok := metadata.GetSupportedFileType(ext); ok {
count++
}
return nil
},
)
return count
}
// hddWorkerCount is the maximum number of concurrent extraction
// workers when the library resides on a spinning disk.
const hddWorkerCount = 2
+11
View File
@@ -54,6 +54,17 @@ type ScanMetrics struct {
Warnings []ScanWarning `json:"warnings"`
}
// ScanProgress is the payload emitted periodically during a scan to
// report live progress to the frontend.
type ScanProgress struct {
Phase string `json:"phase"` // "counting", "scanning", "orphans", "thumbnails"
Total int64 `json:"total"` // total audio files from pre-walk count
Processed int64 `json:"processed"` // added + skipped + updated so far
Added int64 `json:"added"`
Skipped int64 `json:"skipped"`
Updated int64 `json:"updated"`
}
// ScanWarning represents a non-fatal issue encountered during scanning.
type ScanWarning struct {
FilePath string `json:"filePath"`
@@ -33,6 +33,15 @@ import './config-section';
const NS_PER_MS = 1_000_000;
interface ScanProgress {
phase: 'counting' | 'scanning' | 'orphans' | 'thumbnails';
total: number;
processed: number;
added: number;
skipped: number;
updated: number;
}
interface ScanMetrics {
total: number;
loadExisting: number;
@@ -200,6 +209,7 @@ export class ConfigPage extends LitElement {
@state() private selectedDirectory = '';
@state() private scanning = false;
@state() private statusMessage = '';
@state() private scanProgress: ScanProgress | null = null;
@state() private metrics: ScanMetrics | null = null;
@state() private copied = false;
@state() private errorsCopied = false;
@@ -207,6 +217,7 @@ export class ConfigPage extends LitElement {
@state() private concurrencyMode = 'auto';
private cancelScanStarted?: () => void;
private cancelScanProgress?: () => void;
private cancelScanComplete?: () => void;
static override styles = css`
@@ -314,6 +325,46 @@ export class ConfigPage extends LitElement {
color: var(--yj-accent, #ffd43b);
}
/* Progress bar */
.progress-info {
display: flex;
align-items: baseline;
gap: 0.5em;
margin-bottom: 0.5em;
}
.progress-label {
font-weight: 500;
}
.progress-detail {
color: var(--yj-text-tertiary, #868e96);
font-size: 0.95em;
}
.progress-percent {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.progress-phase {
font-weight: 500;
}
.progress-track {
height: 6px;
background: var(--yj-bg-base, #1a1b1e);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--yj-accent, #ffd43b);
border-radius: 3px;
transition: width 300ms ease;
}
/* Error block */
.error-block {
margin-top: 1em;
@@ -568,6 +619,10 @@ export class ConfigPage extends LitElement {
Events.LibraryScanStarted,
this.handleScanStarted,
);
this.cancelScanProgress = EventsOn(
Events.LibraryScanProgress,
this.handleScanProgress,
);
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
this.handleScanComplete,
@@ -577,6 +632,7 @@ export class ConfigPage extends LitElement {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.cancelScanStarted?.();
this.cancelScanProgress?.();
this.cancelScanComplete?.();
}
@@ -604,17 +660,27 @@ export class ConfigPage extends LitElement {
private handleScanStarted = (): void => {
this.scanning = true;
this.statusMessage = 'Scanning...';
this.statusMessage = '';
this.scanProgress = null;
this.metrics = null;
this.copied = false;
this.scanErrors = '';
this.errorsCopied = false;
};
private handleScanProgress = (
progress?: ScanProgress,
): void => {
if (progress) {
this.scanProgress = progress;
}
};
private handleScanComplete = (
metrics?: ScanMetrics,
): void => {
this.scanning = false;
this.scanProgress = null;
this.statusMessage = 'Scan complete.';
if (metrics) {
@@ -1282,7 +1348,9 @@ export class ConfigPage extends LitElement {
<div
class="status-bar ${this.scanning ? 'active' : ''}"
>
${this.statusMessage || 'Ready.'}
${this.scanProgress
? this.renderScanProgress()
: this.statusMessage || 'Ready.'}
</div>
${this.scanErrors
@@ -1331,6 +1399,80 @@ export class ConfigPage extends LitElement {
`;
}
private renderScanProgress() {
const p = this.scanProgress;
if (!p) return nothing;
if (p.phase === 'counting') {
return html`
<div class="progress-phase">
Counting files\u2026
</div>
`;
}
const percent =
p.total > 0
? Math.min(
100,
Math.round(
(p.processed / p.total) * 100,
),
)
: 0;
const phaseLabel: Record<string, string> = {
scanning: 'Scanning',
orphans: 'Cleaning up',
thumbnails: 'Generating thumbnails',
};
const label = phaseLabel[p.phase] ?? 'Scanning';
// Build detail string: "1,247 / 2,013 files (891 new, 23 updated, 356 skipped)"
const parts: string[] = [];
if (p.added > 0)
parts.push(`${p.added.toLocaleString()} new`);
if (p.updated > 0)
parts.push(
`${p.updated.toLocaleString()} updated`,
);
if (p.skipped > 0)
parts.push(
`${p.skipped.toLocaleString()} skipped`,
);
const detail =
p.phase === 'scanning' && p.total > 0
? html`<span class="progress-detail">
${p.processed.toLocaleString()} /
${p.total.toLocaleString()} files${parts.length
? ` (${parts.join(', ')})`
: ''}
</span>`
: nothing;
return html`
<div class="progress-info">
<span class="progress-label">
${label}\u2026
</span>
${detail}
<span class="progress-percent">
${percent}%
</span>
</div>
<div class="progress-track">
<div
class="progress-fill"
style="width: ${percent}%"
></div>
</div>
`;
}
private renderMetrics() {
const m = this.metrics;
@@ -19,6 +19,15 @@ const NS_PER_MS = 1_000_000;
* All duration fields are nanoseconds (Go time.Duration JSON).
* FormatExtraction values are milliseconds (int64 set from Go).
*/
interface ScanProgress {
phase: 'counting' | 'scanning' | 'orphans' | 'thumbnails';
total: number;
processed: number;
added: number;
skipped: number;
updated: number;
}
interface ScanMetrics {
total: number;
loadExisting: number;
@@ -232,12 +241,14 @@ export class LibraryManager extends LitElement {
@state() private selectedDirectory = '';
@state() private scanning = false;
@state() private statusMessage = '';
@state() private scanProgress: ScanProgress | null = null;
@state() private metrics: ScanMetrics | null = null;
@state() private copied = false;
@state() private errorsCopied = false;
@state() private scanErrors = '';
@state() private concurrencyMode = 'auto';
private cancelScanStarted?: () => void;
private cancelScanProgress?: () => void;
private cancelScanComplete?: () => void;
static override styles = css`
@@ -438,6 +449,46 @@ export class LibraryManager extends LitElement {
color: var(--yj-accent, #ffd43b);
}
/* Progress bar */
.progress-info {
display: flex;
align-items: baseline;
gap: 0.5em;
margin-bottom: 0.5em;
}
.progress-label {
font-weight: 500;
}
.progress-detail {
color: var(--yj-text-tertiary, #868e96);
font-size: 0.95em;
}
.progress-percent {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.progress-phase {
font-weight: 500;
}
.progress-track {
height: 6px;
background: var(--yj-bg-base, #1a1b1e);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--yj-accent, #ffd43b);
border-radius: 3px;
transition: width 300ms ease;
}
/* --- Error block --- */
.error-block {
margin-top: 1em;
@@ -576,6 +627,10 @@ export class LibraryManager extends LitElement {
Events.LibraryScanStarted,
this.handleScanStarted,
);
this.cancelScanProgress = EventsOn(
Events.LibraryScanProgress,
this.handleScanProgress,
);
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
this.handleScanComplete,
@@ -585,6 +640,7 @@ export class LibraryManager extends LitElement {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.cancelScanStarted?.();
this.cancelScanProgress?.();
this.cancelScanComplete?.();
}
@@ -635,17 +691,27 @@ export class LibraryManager extends LitElement {
private handleScanStarted = (): void => {
this.scanning = true;
this.statusMessage = 'Scanning...';
this.statusMessage = '';
this.scanProgress = null;
this.metrics = null;
this.copied = false;
this.scanErrors = '';
this.errorsCopied = false;
};
private handleScanProgress = (
progress?: ScanProgress,
): void => {
if (progress) {
this.scanProgress = progress;
}
};
private handleScanComplete = (
metrics?: ScanMetrics,
): void => {
this.scanning = false;
this.scanProgress = null;
this.statusMessage = 'Scan complete.';
if (metrics) {
@@ -803,6 +869,79 @@ export class LibraryManager extends LitElement {
`;
}
private renderScanProgress() {
const p = this.scanProgress;
if (!p) return nothing;
if (p.phase === 'counting') {
return html`
<div class="progress-phase">
Counting files\u2026
</div>
`;
}
const percent =
p.total > 0
? Math.min(
100,
Math.round(
(p.processed / p.total) * 100,
),
)
: 0;
const phaseLabel: Record<string, string> = {
scanning: 'Scanning',
orphans: 'Cleaning up',
thumbnails: 'Generating thumbnails',
};
const label = phaseLabel[p.phase] ?? 'Scanning';
const parts: string[] = [];
if (p.added > 0)
parts.push(`${p.added.toLocaleString()} new`);
if (p.updated > 0)
parts.push(
`${p.updated.toLocaleString()} updated`,
);
if (p.skipped > 0)
parts.push(
`${p.skipped.toLocaleString()} skipped`,
);
const detail =
p.phase === 'scanning' && p.total > 0
? html`<span class="progress-detail">
${p.processed.toLocaleString()} /
${p.total.toLocaleString()} files${parts.length
? ` (${parts.join(', ')})`
: ''}
</span>`
: nothing;
return html`
<div class="progress-info">
<span class="progress-label">
${label}\u2026
</span>
${detail}
<span class="progress-percent">
${percent}%
</span>
</div>
<div class="progress-track">
<div
class="progress-fill"
style="width: ${percent}%"
></div>
</div>
`;
}
private renderMetrics() {
const m = this.metrics;
@@ -1075,7 +1214,9 @@ export class LibraryManager extends LitElement {
<div
class="status-bar ${this.scanning ? 'active' : ''}"
>
${this.statusMessage || 'Ready.'}
${this.scanProgress
? this.renderScanProgress()
: this.statusMessage || 'Ready.'}
</div>
${this.scanErrors
+1
View File
@@ -30,6 +30,7 @@ export const Events = {
// Library events
LibraryScanStarted: "LibraryScanStarted",
LibraryScanProgress: "LibraryScanProgress",
LibraryScanComplete: "LibraryScanComplete",
} as const;