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:
@@ -43,5 +43,6 @@ const (
|
|||||||
// Library events.
|
// Library events.
|
||||||
const (
|
const (
|
||||||
LibraryScanStarted = "LibraryScanStarted"
|
LibraryScanStarted = "LibraryScanStarted"
|
||||||
|
LibraryScanProgress = "LibraryScanProgress"
|
||||||
LibraryScanComplete = "LibraryScanComplete"
|
LibraryScanComplete = "LibraryScanComplete"
|
||||||
)
|
)
|
||||||
|
|||||||
+107
-2
@@ -193,6 +193,20 @@ func (l *Library) Scan() (*ScanMetrics, error) {
|
|||||||
|
|
||||||
runtime.EventsEmit(l.ctx, events.LibraryScanStarted)
|
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 ---
|
// --- Phase 1: load existing files from DB ---
|
||||||
loadStart := time.Now()
|
loadStart := time.Now()
|
||||||
|
|
||||||
@@ -215,8 +229,6 @@ func (l *Library) Scan() (*ScanMetrics, error) {
|
|||||||
"count", len(existingFiles),
|
"count", len(existingFiles),
|
||||||
"library-directory", l.conf.DirectoryPath,
|
"library-directory", l.conf.DirectoryPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
basePath := string(l.conf.DirectoryPath)
|
|
||||||
workChan := make(chan scanWork, 100)
|
workChan := make(chan scanWork, 100)
|
||||||
resultChan := make(chan importResult, 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 ---
|
// --- Phase 4: DB writer goroutine ---
|
||||||
var dbWg sync.WaitGroup
|
var dbWg sync.WaitGroup
|
||||||
|
|
||||||
@@ -460,17 +507,46 @@ func (l *Library) Scan() (*ScanMetrics, error) {
|
|||||||
close(resultChan)
|
close(resultChan)
|
||||||
dbWg.Wait()
|
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
|
// Close thumbnail channel and wait for all thumbnail workers
|
||||||
// to finish. The DB writer has stopped sending work at this
|
// to finish. The DB writer has stopped sending work at this
|
||||||
// point so it is safe to close.
|
// point so it is safe to close.
|
||||||
thumbStart := time.Now()
|
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)
|
close(thumbChan)
|
||||||
thumbWg.Wait()
|
thumbWg.Wait()
|
||||||
|
|
||||||
metrics.ThumbnailWallClock = time.Since(thumbStart)
|
metrics.ThumbnailWallClock = time.Since(thumbStart)
|
||||||
|
|
||||||
// --- Phase 5: orphan cleanup ---
|
// --- 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()
|
orphanStart := time.Now()
|
||||||
|
|
||||||
var removed atomic.Int64
|
var removed atomic.Int64
|
||||||
@@ -557,6 +633,35 @@ func (l *Library) Scan() (*ScanMetrics, error) {
|
|||||||
return metrics, scanErr
|
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
|
// hddWorkerCount is the maximum number of concurrent extraction
|
||||||
// workers when the library resides on a spinning disk.
|
// workers when the library resides on a spinning disk.
|
||||||
const hddWorkerCount = 2
|
const hddWorkerCount = 2
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ type ScanMetrics struct {
|
|||||||
Warnings []ScanWarning `json:"warnings"`
|
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.
|
// ScanWarning represents a non-fatal issue encountered during scanning.
|
||||||
type ScanWarning struct {
|
type ScanWarning struct {
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ import './config-section';
|
|||||||
|
|
||||||
const NS_PER_MS = 1_000_000;
|
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 {
|
interface ScanMetrics {
|
||||||
total: number;
|
total: number;
|
||||||
loadExisting: number;
|
loadExisting: number;
|
||||||
@@ -200,6 +209,7 @@ export class ConfigPage extends LitElement {
|
|||||||
@state() private selectedDirectory = '';
|
@state() private selectedDirectory = '';
|
||||||
@state() private scanning = false;
|
@state() private scanning = false;
|
||||||
@state() private statusMessage = '';
|
@state() private statusMessage = '';
|
||||||
|
@state() private scanProgress: ScanProgress | null = null;
|
||||||
@state() private metrics: ScanMetrics | null = null;
|
@state() private metrics: ScanMetrics | null = null;
|
||||||
@state() private copied = false;
|
@state() private copied = false;
|
||||||
@state() private errorsCopied = false;
|
@state() private errorsCopied = false;
|
||||||
@@ -207,6 +217,7 @@ export class ConfigPage extends LitElement {
|
|||||||
@state() private concurrencyMode = 'auto';
|
@state() private concurrencyMode = 'auto';
|
||||||
|
|
||||||
private cancelScanStarted?: () => void;
|
private cancelScanStarted?: () => void;
|
||||||
|
private cancelScanProgress?: () => void;
|
||||||
private cancelScanComplete?: () => void;
|
private cancelScanComplete?: () => void;
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
@@ -314,6 +325,46 @@ export class ConfigPage extends LitElement {
|
|||||||
color: var(--yj-accent, #ffd43b);
|
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 */
|
||||||
.error-block {
|
.error-block {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
@@ -568,6 +619,10 @@ export class ConfigPage extends LitElement {
|
|||||||
Events.LibraryScanStarted,
|
Events.LibraryScanStarted,
|
||||||
this.handleScanStarted,
|
this.handleScanStarted,
|
||||||
);
|
);
|
||||||
|
this.cancelScanProgress = EventsOn(
|
||||||
|
Events.LibraryScanProgress,
|
||||||
|
this.handleScanProgress,
|
||||||
|
);
|
||||||
this.cancelScanComplete = EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
this.handleScanComplete,
|
this.handleScanComplete,
|
||||||
@@ -577,6 +632,7 @@ export class ConfigPage extends LitElement {
|
|||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.cancelScanStarted?.();
|
this.cancelScanStarted?.();
|
||||||
|
this.cancelScanProgress?.();
|
||||||
this.cancelScanComplete?.();
|
this.cancelScanComplete?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,17 +660,27 @@ export class ConfigPage extends LitElement {
|
|||||||
|
|
||||||
private handleScanStarted = (): void => {
|
private handleScanStarted = (): void => {
|
||||||
this.scanning = true;
|
this.scanning = true;
|
||||||
this.statusMessage = 'Scanning...';
|
this.statusMessage = '';
|
||||||
|
this.scanProgress = null;
|
||||||
this.metrics = null;
|
this.metrics = null;
|
||||||
this.copied = false;
|
this.copied = false;
|
||||||
this.scanErrors = '';
|
this.scanErrors = '';
|
||||||
this.errorsCopied = false;
|
this.errorsCopied = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleScanProgress = (
|
||||||
|
progress?: ScanProgress,
|
||||||
|
): void => {
|
||||||
|
if (progress) {
|
||||||
|
this.scanProgress = progress;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private handleScanComplete = (
|
private handleScanComplete = (
|
||||||
metrics?: ScanMetrics,
|
metrics?: ScanMetrics,
|
||||||
): void => {
|
): void => {
|
||||||
this.scanning = false;
|
this.scanning = false;
|
||||||
|
this.scanProgress = null;
|
||||||
this.statusMessage = 'Scan complete.';
|
this.statusMessage = 'Scan complete.';
|
||||||
|
|
||||||
if (metrics) {
|
if (metrics) {
|
||||||
@@ -1282,7 +1348,9 @@ export class ConfigPage extends LitElement {
|
|||||||
<div
|
<div
|
||||||
class="status-bar ${this.scanning ? 'active' : ''}"
|
class="status-bar ${this.scanning ? 'active' : ''}"
|
||||||
>
|
>
|
||||||
${this.statusMessage || 'Ready.'}
|
${this.scanProgress
|
||||||
|
? this.renderScanProgress()
|
||||||
|
: this.statusMessage || 'Ready.'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this.scanErrors
|
${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() {
|
private renderMetrics() {
|
||||||
const m = this.metrics;
|
const m = this.metrics;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ const NS_PER_MS = 1_000_000;
|
|||||||
* All duration fields are nanoseconds (Go time.Duration JSON).
|
* All duration fields are nanoseconds (Go time.Duration JSON).
|
||||||
* FormatExtraction values are milliseconds (int64 set from Go).
|
* 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 {
|
interface ScanMetrics {
|
||||||
total: number;
|
total: number;
|
||||||
loadExisting: number;
|
loadExisting: number;
|
||||||
@@ -232,12 +241,14 @@ export class LibraryManager extends LitElement {
|
|||||||
@state() private selectedDirectory = '';
|
@state() private selectedDirectory = '';
|
||||||
@state() private scanning = false;
|
@state() private scanning = false;
|
||||||
@state() private statusMessage = '';
|
@state() private statusMessage = '';
|
||||||
|
@state() private scanProgress: ScanProgress | null = null;
|
||||||
@state() private metrics: ScanMetrics | null = null;
|
@state() private metrics: ScanMetrics | null = null;
|
||||||
@state() private copied = false;
|
@state() private copied = false;
|
||||||
@state() private errorsCopied = false;
|
@state() private errorsCopied = false;
|
||||||
@state() private scanErrors = '';
|
@state() private scanErrors = '';
|
||||||
@state() private concurrencyMode = 'auto';
|
@state() private concurrencyMode = 'auto';
|
||||||
private cancelScanStarted?: () => void;
|
private cancelScanStarted?: () => void;
|
||||||
|
private cancelScanProgress?: () => void;
|
||||||
private cancelScanComplete?: () => void;
|
private cancelScanComplete?: () => void;
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
@@ -438,6 +449,46 @@ export class LibraryManager extends LitElement {
|
|||||||
color: var(--yj-accent, #ffd43b);
|
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 --- */
|
||||||
.error-block {
|
.error-block {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
@@ -576,6 +627,10 @@ export class LibraryManager extends LitElement {
|
|||||||
Events.LibraryScanStarted,
|
Events.LibraryScanStarted,
|
||||||
this.handleScanStarted,
|
this.handleScanStarted,
|
||||||
);
|
);
|
||||||
|
this.cancelScanProgress = EventsOn(
|
||||||
|
Events.LibraryScanProgress,
|
||||||
|
this.handleScanProgress,
|
||||||
|
);
|
||||||
this.cancelScanComplete = EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
this.handleScanComplete,
|
this.handleScanComplete,
|
||||||
@@ -585,6 +640,7 @@ export class LibraryManager extends LitElement {
|
|||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.cancelScanStarted?.();
|
this.cancelScanStarted?.();
|
||||||
|
this.cancelScanProgress?.();
|
||||||
this.cancelScanComplete?.();
|
this.cancelScanComplete?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,17 +691,27 @@ export class LibraryManager extends LitElement {
|
|||||||
|
|
||||||
private handleScanStarted = (): void => {
|
private handleScanStarted = (): void => {
|
||||||
this.scanning = true;
|
this.scanning = true;
|
||||||
this.statusMessage = 'Scanning...';
|
this.statusMessage = '';
|
||||||
|
this.scanProgress = null;
|
||||||
this.metrics = null;
|
this.metrics = null;
|
||||||
this.copied = false;
|
this.copied = false;
|
||||||
this.scanErrors = '';
|
this.scanErrors = '';
|
||||||
this.errorsCopied = false;
|
this.errorsCopied = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleScanProgress = (
|
||||||
|
progress?: ScanProgress,
|
||||||
|
): void => {
|
||||||
|
if (progress) {
|
||||||
|
this.scanProgress = progress;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private handleScanComplete = (
|
private handleScanComplete = (
|
||||||
metrics?: ScanMetrics,
|
metrics?: ScanMetrics,
|
||||||
): void => {
|
): void => {
|
||||||
this.scanning = false;
|
this.scanning = false;
|
||||||
|
this.scanProgress = null;
|
||||||
this.statusMessage = 'Scan complete.';
|
this.statusMessage = 'Scan complete.';
|
||||||
|
|
||||||
if (metrics) {
|
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() {
|
private renderMetrics() {
|
||||||
const m = this.metrics;
|
const m = this.metrics;
|
||||||
|
|
||||||
@@ -1075,7 +1214,9 @@ export class LibraryManager extends LitElement {
|
|||||||
<div
|
<div
|
||||||
class="status-bar ${this.scanning ? 'active' : ''}"
|
class="status-bar ${this.scanning ? 'active' : ''}"
|
||||||
>
|
>
|
||||||
${this.statusMessage || 'Ready.'}
|
${this.scanProgress
|
||||||
|
? this.renderScanProgress()
|
||||||
|
: this.statusMessage || 'Ready.'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this.scanErrors
|
${this.scanErrors
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const Events = {
|
|||||||
|
|
||||||
// Library events
|
// Library events
|
||||||
LibraryScanStarted: "LibraryScanStarted",
|
LibraryScanStarted: "LibraryScanStarted",
|
||||||
|
LibraryScanProgress: "LibraryScanProgress",
|
||||||
LibraryScanComplete: "LibraryScanComplete",
|
LibraryScanComplete: "LibraryScanComplete",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user