music: /manifest is a pure read — never triggers a (re)build
Reading the manifest (which every app refresh hits) used to call ensureIndexFresh(), kicking off a debounced rebuild — and after the CACHE_VERSION bump that meant a plain refresh could launch a full library rebuild. Make reads side-effect-free: /manifest now just returns the last completed index. Builds are explicit only (POST /reindex or the SSE stream); pick up disk changes by reindexing. Removes the now-unused ensureIndexFresh + lastBuildFinishedAt, and drops /manifest from the 30-min per-request timeout extension (both hops) since it no longer blocks on a build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -93,7 +93,7 @@ musicRouter.all('/*', async (ctx) => {
|
|||||||
// A from-scratch reindex holds this proxied connection open for minutes with no bytes flowing, which
|
// A from-scratch reindex holds this proxied connection open for minutes with no bytes flowing, which
|
||||||
// the main server's 60s idle timeout would drop. Extend it to 30 min for the build/progress endpoints
|
// the main server's 60s idle timeout would drop. Extend it to 30 min for the build/progress endpoints
|
||||||
// (Bun passes the server as Hono's env). Matches the sidecar's own per-request extension.
|
// (Bun passes the server as Hono's env). Matches the sidecar's own per-request extension.
|
||||||
if (subpath === '/reindex' || subpath === '/manifest' || subpath === '/reindex/stream') {
|
if (subpath === '/reindex' || subpath === '/reindex/stream') {
|
||||||
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
||||||
try {
|
try {
|
||||||
server?.timeout?.(ctx.req.raw, 1800);
|
server?.timeout?.(ctx.req.raw, 1800);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { createSidecarConnector } from '../connect';
|
|||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
import {
|
import {
|
||||||
reindexNow,
|
reindexNow,
|
||||||
ensureIndexFresh,
|
|
||||||
getIndexStatus,
|
getIndexStatus,
|
||||||
getManifest,
|
getManifest,
|
||||||
albumVersion,
|
albumVersion,
|
||||||
@@ -34,8 +33,8 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|||||||
//
|
//
|
||||||
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
||||||
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
||||||
// GET /manifest ensures a fresh index (debounced rebuild) then returns
|
// GET /manifest pure read of the last completed index (NO build triggered) —
|
||||||
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, disco? } } }
|
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
|
||||||
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
||||||
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
|
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
|
||||||
// GET /poster?path=<rel>&file=<video> compressed video poster (frame grab). ETag: <v>; 304. 404 if none.
|
// GET /poster?path=<rel>&file=<video> compressed video poster (frame grab). ETag: <v>; 304. 404 if none.
|
||||||
@@ -80,8 +79,8 @@ const server = Bun.serve({
|
|||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
// A from-scratch reindex can take many minutes with no bytes flowing on the triggering request.
|
// A from-scratch reindex can take many minutes with no bytes flowing on the triggering request.
|
||||||
// Give the build-triggering + progress endpoints a 30-min idle timeout so they aren't dropped.
|
// Give the build endpoints a 30-min idle timeout so they aren't dropped (/manifest is a pure read now).
|
||||||
if (url.pathname === '/reindex' || url.pathname === '/manifest' || url.pathname === '/reindex/stream') {
|
if (url.pathname === '/reindex' || url.pathname === '/reindex/stream') {
|
||||||
server.timeout(req, 1800);
|
server.timeout(req, 1800);
|
||||||
}
|
}
|
||||||
const json = (data: unknown, init?: ResponseInit) =>
|
const json = (data: unknown, init?: ResponseInit) =>
|
||||||
@@ -157,9 +156,8 @@ const server = Bun.serve({
|
|||||||
|
|
||||||
// ── Sync surface ──
|
// ── Sync surface ──
|
||||||
if (url.pathname === '/manifest') {
|
if (url.pathname === '/manifest') {
|
||||||
// Every app refresh funnels through here, so rebuild the index first (debounced) — this is what
|
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
|
||||||
// makes on-disk changes show up on a plain refresh, not only via the explicit reindex sheet.
|
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
|
||||||
await ensureIndexFresh();
|
|
||||||
return json(await getManifest());
|
return json(await getManifest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -499,40 +499,22 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
|||||||
return getIndexStatus();
|
return getIndexStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Coalesced / debounced build entry points ──
|
// ── Coalesced build entry point ──
|
||||||
// So reads (the manifest fetch that every app refresh funnels through) can ensure freshness without
|
// Builds are EXPLICIT only (POST /reindex, the SSE stream). Reads (/manifest, /meta, /cover, …) never
|
||||||
// stampeding builds: concurrent callers join one in-flight build, and back-to-back reads within the
|
// trigger a build — a manifest fetch just returns the last completed index. Route all build triggers
|
||||||
// debounce window skip rebuilding. Route ALL build triggers through reindexNow so there's one tracker.
|
// through reindexNow so concurrent callers join one in-flight build instead of stampeding.
|
||||||
|
|
||||||
let inflightBuild: Promise<IndexStatus> | null = null;
|
let inflightBuild: Promise<IndexStatus> | null = null;
|
||||||
let lastBuildFinishedAt = 0;
|
|
||||||
|
|
||||||
/** Run a build, joining an in-flight one instead of starting a second; resolves when it completes. */
|
/** Run a build, joining an in-flight one instead of starting a second; resolves when it completes. */
|
||||||
export function reindexNow(): Promise<IndexStatus> {
|
export function reindexNow(): Promise<IndexStatus> {
|
||||||
if (inflightBuild) return inflightBuild;
|
if (inflightBuild) return inflightBuild;
|
||||||
inflightBuild = buildMusicIndex()
|
inflightBuild = buildMusicIndex().finally(() => {
|
||||||
.then((s) => {
|
|
||||||
lastBuildFinishedAt = Date.now();
|
|
||||||
return s;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflightBuild = null;
|
inflightBuild = null;
|
||||||
});
|
});
|
||||||
return inflightBuild;
|
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<void> {
|
|
||||||
if (inflightBuild) {
|
|
||||||
await inflightBuild;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Date.now() - lastBuildFinishedAt < debounceMs) return;
|
|
||||||
await reindexNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<void> {
|
async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<void> {
|
||||||
status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
|
status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
|
||||||
status.foldersScanned += 1;
|
status.foldersScanned += 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user