music: fix stale cover/meta on the clients (cache revalidation) + purge removed covers
Two problems behind "deleted/changed the folder image but it still shows" (on both web and app): 1. Client caching. Cover/meta/etc. were served with an ETag(=v) but NO Cache-Control, so browsers served them straight from the heuristic cache at the same URL — a changed cover kept showing the old image. And the platform proxy never forwarded If-None-Match, so the ETag revalidation couldn't work anyway. Now the sidecar sends `Cache-Control: no-cache` on every version- stamped artifact (cover/meta/poster/lyrics/image) + the manifest, and the proxy forwards If-None-Match → the client revalidates every time and gets a cheap 304 when unchanged, a fresh 200 when v changed. 2. Removed covers lingered. On rebuild the indexer only (over)wrote cover.jpg when a source cover existed — a deleted or now-undecodable source left the old cover.jpg in the cache (still served, still cover:true). Now it clears cover.jpg first and regenerates only if there's a valid source. Verified live against a booted sidecar: cover carries no-cache + ETag, a matching If-None-Match → 304, and deleting the source folder.jpg drops the cached cover (404, meta.cover cleared, manifest cover:false). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,10 @@ musicRouter.all('/*', async (ctx) => {
|
||||
if (range) headers['Range'] = range;
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// Forward conditional-request headers so the sidecar's ETag(=v) revalidation works: a `no-cache`
|
||||
// artifact (cover/meta/…) gets a cheap 304 when unchanged, a fresh 200 when its version changed.
|
||||
const inm = ctx.req.header('if-none-match');
|
||||
if (inm) headers['If-None-Match'] = inm;
|
||||
// Forward the authenticated user id so the sidecar can serve its per-user state routes (favorites /
|
||||
// now-playing / playlists). The sidecar binds loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
@@ -155,6 +155,11 @@ const server = Bun.serve({
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
});
|
||||
// Version-stamped artifacts (cover/meta/…) and the manifest carry an ETag(=v) but must be REVALIDATED,
|
||||
// not served blind from the browser cache — otherwise a changed cover keeps showing the old image at
|
||||
// the same URL. `no-cache` = cache but always revalidate; the ETag/If-None-Match then makes it a cheap
|
||||
// 304 when nothing changed. (The platform proxy forwards If-None-Match so this works end-to-end.)
|
||||
const NO_CACHE = { 'Cache-Control': 'no-cache' } as const;
|
||||
|
||||
if (url.pathname === '/health') return new Response('ok');
|
||||
|
||||
@@ -231,7 +236,7 @@ const server = Bun.serve({
|
||||
if (url.pathname === '/manifest') {
|
||||
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
|
||||
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
|
||||
return json(await getManifest());
|
||||
return json(await getManifest(), { headers: NO_CACHE });
|
||||
}
|
||||
|
||||
// Video poster (a compressed frame grab). Keyed by the video's folder rel + its filename.
|
||||
@@ -243,9 +248,9 @@ const server = Bun.serve({
|
||||
if (!posterPath) return new Response('Invalid path', { status: 400 });
|
||||
if (!(await Bun.file(posterPath).exists())) return new Response('Not found', { status: 404 });
|
||||
const v = await albumVersion(rel);
|
||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
|
||||
return new Response(Bun.file(posterPath), {
|
||||
headers: { 'Content-Type': 'image/jpeg', ...(v ? { ETag: v } : {}) },
|
||||
headers: { 'Content-Type': 'image/jpeg', ...NO_CACHE, ...(v ? { ETag: v } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,9 +263,15 @@ const server = Bun.serve({
|
||||
const p = lyricsFilePath(rel, file, fmt);
|
||||
if (p && (await Bun.file(p).exists())) {
|
||||
const v = await albumVersion(rel);
|
||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
||||
if (v && req.headers.get('if-none-match') === v)
|
||||
return new Response(null, { status: 304, headers: NO_CACHE });
|
||||
return new Response(Bun.file(p), {
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Lyrics-Format': fmt, ...(v ? { ETag: v } : {}) },
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Lyrics-Format': fmt,
|
||||
...NO_CACHE,
|
||||
...(v ? { ETag: v } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -277,11 +288,11 @@ const server = Bun.serve({
|
||||
if (abs !== MUSIC_ROOT && !abs.startsWith(MUSIC_ROOT + '/')) return new Response('Invalid path', { status: 400 });
|
||||
if (!(await Bun.file(abs).exists())) return new Response('Not found', { status: 404 });
|
||||
const v = await albumVersion(rel);
|
||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
|
||||
const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
|
||||
const type =
|
||||
ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
|
||||
return new Response(Bun.file(abs), { headers: { 'Content-Type': type, ...(v ? { ETag: v } : {}) } });
|
||||
return new Response(Bun.file(abs), { headers: { 'Content-Type': type, ...NO_CACHE, ...(v ? { ETag: v } : {}) } });
|
||||
}
|
||||
|
||||
if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
|
||||
@@ -296,9 +307,9 @@ const server = Bun.serve({
|
||||
if (!(await Bun.file(spec.file).exists())) return new Response('Not found', { status: 404 });
|
||||
|
||||
const v = await albumVersion(rel);
|
||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
|
||||
return new Response(Bun.file(spec.file), {
|
||||
headers: { 'Content-Type': spec.type, ...(v ? { ETag: v } : {}) },
|
||||
headers: { 'Content-Type': spec.type, ...NO_CACHE, ...(v ? { ETag: v } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -811,6 +811,10 @@ async function buildFolder(
|
||||
} else {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
|
||||
// Clear any prior cached cover first, then regenerate from the current source (if any). Otherwise a
|
||||
// removed OR now-undecodable source cover leaves the old cover.jpg behind — which keeps being served
|
||||
// and reported as cover:true (the "deleted the folder image but it still shows" bug).
|
||||
await rm(coverJpg, { force: true }).catch(() => {});
|
||||
if (coverName) {
|
||||
const ok = await compressCover(join(dirAbs, coverName), coverJpg);
|
||||
if (ok) status.coversSaved += 1;
|
||||
|
||||
Reference in New Issue
Block a user