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:
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Trigger the music library index on the officer-music sidecar and follow its progress live,
|
||||
* ending with a summary report. Run from the platform repo: bun scripts/reindex-music.ts
|
||||
*
|
||||
* It reads the sidecar's port from DATA_PATH/music/.server (written by the sidecar on startup) and
|
||||
* consumes its /reindex/stream SSE endpoint — the same stream the app subscribes to.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const PORT_FILE = join(DATA_PATH, 'music', '.server');
|
||||
|
||||
type Progress = {
|
||||
running: boolean;
|
||||
foldersScanned: number;
|
||||
albumsBuilt: number;
|
||||
albumsSkipped: number;
|
||||
tracksIndexed: number;
|
||||
coversSaved: number;
|
||||
currentPath: string;
|
||||
};
|
||||
type Report = {
|
||||
albums: number;
|
||||
built: number;
|
||||
skipped: number;
|
||||
foldersScanned: number;
|
||||
tracksIndexed: number;
|
||||
coversSaved: number;
|
||||
elapsedSec: number;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function readPort(): number {
|
||||
try {
|
||||
const p = parseInt(readFileSync(PORT_FILE, 'utf8').trim(), 10);
|
||||
if (Number.isInteger(p) && p > 0) return p;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
console.error(`✗ Could not read the sidecar port from ${PORT_FILE}.`);
|
||||
console.error(' Is officer-music running? pm2 restart officer-music');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isTTY = Boolean(process.stdout.isTTY);
|
||||
const cols = () => (process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100);
|
||||
|
||||
function printProgress(p: Progress): void {
|
||||
const line =
|
||||
`♪ indexing… folders ${p.foldersScanned} · tracks ${p.tracksIndexed} · ` +
|
||||
`built ${p.albumsBuilt} · skipped ${p.albumsSkipped} · covers ${p.coversSaved}` +
|
||||
(p.currentPath ? ` · ${p.currentPath}` : '');
|
||||
if (isTTY) {
|
||||
const clipped = line.length > cols() - 1 ? line.slice(0, cols() - 2) + '…' : line;
|
||||
process.stdout.write('\r\x1b[2K' + clipped);
|
||||
} else {
|
||||
process.stdout.write(line + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
function printReport(r: Report): void {
|
||||
if (isTTY) process.stdout.write('\r\x1b[2K');
|
||||
const l = (k: string, v: string | number) => console.log(` ${k.padEnd(9)} ${v}`);
|
||||
console.log('\n─── Music index complete ───');
|
||||
l('Albums:', `${r.albums} (${r.built} built, ${r.skipped} unchanged)`);
|
||||
l('Tracks:', `${r.tracksIndexed} indexed`);
|
||||
l('Covers:', `${r.coversSaved} compressed`);
|
||||
l('Folders:', `${r.foldersScanned} scanned`);
|
||||
l('Elapsed:', `${r.elapsedSec}s`);
|
||||
if (r.error) l('Error:', r.error);
|
||||
console.log('────────────────────────────\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const port = readPort();
|
||||
const url = `http://127.0.0.1:${port}/reindex/stream`;
|
||||
console.log(`Triggering music index via ${url}\n`);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch (err) {
|
||||
console.error(`✗ Could not reach the sidecar at 127.0.0.1:${port}: ${String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
console.error(`✗ Sidecar returned ${res.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let sep: number;
|
||||
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
||||
const frame = buf.slice(0, sep);
|
||||
buf = buf.slice(sep + 2);
|
||||
let event = 'message';
|
||||
let data = '';
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
if (event === 'progress') printProgress(JSON.parse(data) as Progress);
|
||||
else if (event === 'done') printReport(JSON.parse(data) as Report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user