diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts index 409f76d7..f8054e59 100644 --- a/src/servers/sidecar/music/index.ts +++ b/src/servers/sidecar/music/index.ts @@ -4,7 +4,8 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { streamAudioFile } from './stream-audio'; import { - buildMusicIndex, + reindexNow, + ensureIndexFresh, getIndexStatus, getManifest, albumVersion, @@ -31,7 +32,8 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); // // GET /stream?path= audio with Range→206 (Content-Range/Length/Accept-Ranges) // + `X-Audio-Duration` (seconds, ffprobe). 400/404/416. -// GET /manifest { version, generatedAt, albums: { "": { v, cover, tracks, disco? } } } +// GET /manifest ensures a fresh index (debounced rebuild) then returns +// { version, generatedAt, albums: { "": { v, cover, tracks, disco? } } } // GET /meta?path= album meta.json (IndexMeta). ETag: ; If-None-Match → 304. // GET /cover?path= compressed cover.jpg. ETag: ; If-None-Match → 304. // GET /discography?path= artist discography.json (only where manifest entry has @@ -39,7 +41,7 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); // "" } }, Type ∈ Studio/Live/Compilation/Single/EP/… // ETag: ; If-None-Match → 304. Source: each artist folder's // _discography.md (normalized; the md itself is never modified). -// POST /reindex start an async build; returns IndexStatus (running: true). +// POST /reindex run the build to COMPLETION, then return the final IndexStatus. // GET /reindex/status IndexStatus snapshot. // GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only). // `event: progress` (IndexStatus) throttled ~200ms, then one @@ -85,8 +87,11 @@ const server = Bun.serve({ // ── Index build ── if (url.pathname === '/reindex') { if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 }); - void buildMusicIndex(); // fire-and-forget; sets running=true synchronously before the first await - return json(getIndexStatus()); + // Run the resync to completion, THEN respond — so the caller's manifest read right after is fresh. + // Incremental builds are near-instant (unchanged albums skip by version stamp). reindexNow joins + // an in-flight build rather than starting a second. + const result = await reindexNow(); + return json(result); } if (url.pathname === '/reindex/status') return json(getIndexStatus()); @@ -94,7 +99,7 @@ const server = Bun.serve({ // streams `progress` events until the build finishes, ending with a `done` event carrying the report. if (url.pathname === '/reindex/stream') { const trigger = url.searchParams.get('trigger') !== '0'; - if (trigger && !getIndexStatus().running) void buildMusicIndex(); + if (trigger) void reindexNow(); const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -139,7 +144,12 @@ const server = Bun.serve({ } // ── Sync surface ── - if (url.pathname === '/manifest') return json(await getManifest()); + if (url.pathname === '/manifest') { + // Every app refresh funnels through here, so rebuild the index first (debounced) — this is what + // makes on-disk changes show up on a plain refresh, not only via the explicit reindex sheet. + await ensureIndexFresh(); + return json(await getManifest()); + } if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') { const rel = url.searchParams.get('path'); diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index 6f6d611c..e8c9f5f9 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -295,6 +295,7 @@ export async function buildMusicIndex(): Promise { currentPath: '', error: null, }); + console.log('[music] resync started'); const prev = await loadManifest(); const next: Manifest = { version: 1, generatedAt: status.startedAt!, albums: {} }; @@ -320,10 +321,52 @@ export async function buildMusicIndex(): Promise { status.finishedAt = Date.now(); status.currentPath = ''; emitProgress(true); // final push — signals 'done' to SSE subscribers + const r = buildReport(); + if (r.error) { + console.error(`[music] resync failed after ${r.elapsedSec}s: ${r.error}`); + } else { + console.log( + `[music] resync done — ${r.built} built, ${r.skipped} skipped, ${r.coversSaved} covers, ${r.tracksIndexed} tracks, ${r.foldersScanned} folders in ${r.elapsedSec}s`, + ); + } } return getIndexStatus(); } +// ── Coalesced / debounced build entry points ── +// So reads (the manifest fetch that every app refresh funnels through) can ensure freshness without +// stampeding builds: concurrent callers join one in-flight build, and back-to-back reads within the +// debounce window skip rebuilding. Route ALL build triggers through reindexNow so there's one tracker. + +let inflightBuild: Promise | null = null; +let lastBuildFinishedAt = 0; + +/** Run a build, joining an in-flight one instead of starting a second; resolves when it completes. */ +export function reindexNow(): Promise { + if (inflightBuild) return inflightBuild; + inflightBuild = buildMusicIndex() + .then((s) => { + lastBuildFinishedAt = Date.now(); + return s; + }) + .finally(() => { + inflightBuild = null; + }); + return inflightBuild; +} + +/** Ensure the index reflects recent on-disk changes before a read: await any in-flight build, else + * rebuild unless one finished within `debounceMs` (so a refresh that reads the manifest twice in a + * row rebuilds once, not twice). */ +export async function ensureIndexFresh(debounceMs = 3000): Promise { + if (inflightBuild) { + await inflightBuild; + return; + } + if (Date.now() - lastBuildFinishedAt < debounceMs) return; + await reindexNow(); +} + async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise { status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.'; status.foldersScanned += 1; @@ -363,11 +406,13 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise f !== null); const outputsExist = expected.every((f) => existsSync(join(cacheDir, f))); @@ -378,7 +423,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise