music: SSE reindex progress stream + reindex-music CLI
Adds a live progress channel for the library index: - indexer.ts: progress subscribers (onIndexProgress) + throttled emit during the walk, and buildReport() for a final summary. - sidecar: GET /reindex/stream (SSE) — triggers a build if idle (?trigger=0 to watch only), streams `progress` events, ends with a `done` event carrying the report; auto-proxied at /api/music/reindex/stream for the app. Sidecar also writes DATA_PATH/music/.server (its port) for local tooling. - scripts/reindex-music.ts: CLI that reads the port file, follows the SSE, prints live progress + a final report. Run: bun scripts/reindex-music.ts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,57 @@ export function getIndexStatus(): IndexStatus {
|
||||
return { ...status };
|
||||
}
|
||||
|
||||
export type IndexReport = {
|
||||
albums: number; // albums with content (built + skipped)
|
||||
built: number;
|
||||
skipped: number;
|
||||
foldersScanned: number;
|
||||
tracksIndexed: number;
|
||||
coversSaved: number;
|
||||
elapsedSec: number;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export function buildReport(s: IndexStatus = status): IndexReport {
|
||||
const elapsed = s.startedAt && s.finishedAt ? (s.finishedAt - s.startedAt) / 1000 : 0;
|
||||
return {
|
||||
albums: s.albumsBuilt + s.albumsSkipped,
|
||||
built: s.albumsBuilt,
|
||||
skipped: s.albumsSkipped,
|
||||
foldersScanned: s.foldersScanned,
|
||||
tracksIndexed: s.tracksIndexed,
|
||||
coversSaved: s.coversSaved,
|
||||
elapsedSec: Math.round(elapsed * 10) / 10,
|
||||
error: s.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Progress subscribers (for SSE) ──
|
||||
|
||||
type ProgressListener = (s: IndexStatus) => void;
|
||||
const listeners = new Set<ProgressListener>();
|
||||
let lastEmit = 0;
|
||||
|
||||
export function onIndexProgress(cb: ProgressListener): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
/** Notify subscribers of progress; throttled to ~200ms unless `force` (e.g. terminal 'done'). */
|
||||
function emitProgress(force = false): void {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastEmit < 200) return;
|
||||
lastEmit = now;
|
||||
const snap = getIndexStatus();
|
||||
for (const cb of listeners) {
|
||||
try {
|
||||
cb(snap);
|
||||
} catch {
|
||||
/* ignore listener errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function isAudio(name: string): boolean {
|
||||
@@ -226,6 +277,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
status.running = false;
|
||||
status.finishedAt = Date.now();
|
||||
status.currentPath = '';
|
||||
emitProgress(true); // final push — signals 'done' to SSE subscribers
|
||||
}
|
||||
return getIndexStatus();
|
||||
}
|
||||
@@ -233,6 +285,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<void> {
|
||||
status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
|
||||
status.foldersScanned += 1;
|
||||
emitProgress();
|
||||
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
@@ -276,6 +329,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
||||
const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => {
|
||||
const t = await ffprobeTrack(join(dirAbs, name), name);
|
||||
status.tracksIndexed += 1;
|
||||
emitProgress();
|
||||
return t;
|
||||
});
|
||||
const meta: IndexMeta = { path: rel, cover: coverName ? 'cover.jpg' : undefined, tracks };
|
||||
|
||||
Reference in New Issue
Block a user