diff --git a/backend/events/events.go b/backend/events/events.go index c298213..5fbdb9d 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -43,5 +43,6 @@ const ( // Library events. const ( LibraryScanStarted = "LibraryScanStarted" + LibraryScanProgress = "LibraryScanProgress" LibraryScanComplete = "LibraryScanComplete" ) diff --git a/backend/library/library.go b/backend/library/library.go index 6d528d6..ef0aa96 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -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 diff --git a/backend/library/metrics.go b/backend/library/metrics.go index e5435aa..01835e4 100644 --- a/backend/library/metrics.go +++ b/backend/library/metrics.go @@ -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"` diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index 511adfb..cc2b328 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -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 {
${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` +