--- a/server/media-cache.js +++ b/server/media-cache.js @@ -24,6 +24,7 @@ statSync, statfsSync, unlinkSync, } from 'node:fs'; import { join } from 'node:path'; +import { sha256File } from './hash.js'; export const HIGH = 0; // explicit save / Broken re-download export const LOW = 1; // auto-cache after a play @@ -167,6 +168,9 @@ freeBytes = () => { const s = statfsSync(dir); return s.bavail * s.bsize; }, now = () => Date.now(), log = console, + hashFile = sha256File, // server-computed SHA-256 = the file's P2P content id + onReady = null, // ({ id, gen, path, sha256, size, … }) after a validated copy lands + backfillDelayMs = 30_000, // hash pre-existing copies this long after init() } = {}) { const tmpDir = join(dir, '.tmp'); const fileFor = (id, gen, kind = 'mp4') => join(dir, `${id}.${gen}.${kind}`); @@ -236,6 +240,20 @@ } } + // Tell the P2P layer a validated file with a server-computed hash exists. + // Never throws and never delays the caller. + function notifyReady(id, gen, sha256, probe, metaJson) { + if (!onReady || !sha256) return; + let meta = {}; + try { meta = JSON.parse(metaJson || '{}'); } catch { /* keep {} */ } + Promise.resolve() + .then(() => onReady({ + id, gen, path: fileFor(id, gen), sha256, size: probe.size, height: probe.height, + vcodec: probe.vcodec, acodec: probe.acodec, duration: probe.duration, meta, + })) + .catch((e) => log.warn?.(`[media] onReady ${id} failed: ${e.message}`)); + } + async function setStatus(job, status) { job.status = status; await db.upsertMedia(job.id, { status, updated_at: now() }); @@ -308,6 +326,7 @@ '-map', '0:a:0', '-c', 'copy', '-movflags', '+faststart', '-f', 'mp4', m4a]); if (ex.code !== 0 || sizeOf(m4a) < 1024) throw new Error('audio sidecar failed: ' + tail(ex.stderr)); + const sha256 = await hashFile(norm); const prev = await db.getMedia(id); const gen = ((prev && prev.gen) || 0) + 1; renameSync(norm, fileFor(id, gen)); @@ -318,13 +337,14 @@ size: probe.size + sizeOf(fileFor(id, gen, 'm4a')), height: probe.height, vcodec: probe.vcodec, acodec: probe.acodec, duration: probe.duration, optimized: 0, meta: JSON.stringify(metaFromInfo(info, probe.duration)), - attempts: 0, error: null, retry_at: 0, updated_at: t, last_access: t, + attempts: 0, error: null, retry_at: 0, updated_at: t, last_access: t, sha256, }; await db.upsertMedia(id, fields); removeFiles(id, gen); job.status = 'ready'; log.info?.(`[media] cached ${id} ${probe.height}p ${fmtMB(fields.size)}`); enqueueOptimize(id); + notifyReady(id, gen, sha256, probe, fields.meta); return { ...(prev || {}), video_id: id, ...fields }; } catch (err) { job.status = 'failed'; @@ -427,6 +447,7 @@ log.info?.(`[media] kept original ${id}: ${probe.vcodec} ${fmtMB(probe.size)} vs ${fmtMB(oldSize)}`); return; } + const sha256 = await hashFile(out); const gen = row.gen + 1; renameSync(out, fileFor(id, gen)); const oldAudio = fileFor(id, row.gen, 'm4a'); @@ -434,8 +455,9 @@ try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); } await db.upsertMedia(id, { gen, size: probe.size + sizeOf(newAudio), height: probe.height, vcodec: probe.vcodec, - duration: probe.duration, optimized: OPT_REV, updated_at: now(), + duration: probe.duration, optimized: OPT_REV, updated_at: now(), sha256, }); + notifyReady(id, gen, sha256, probe, cur.meta); removeFiles(id, gen, OLD_GEN_GRACE_MS); log.info?.(`[media] optimized ${id}: ${fmtMB(oldSize)} → ${fmtMB(probe.size)} (${probe.vcodec})`); } catch (err) { @@ -622,7 +644,34 @@ } const s = await db.mediaStats(); log.info?.(`[media] ${s.count} cached (${fmtMB(s.bytes)}), ${resumed} job(s) resumed, dir ${dir}`); + if (backfillDelayMs >= 0) { + const t = setTimeout(() => backfillHashes().catch(() => {}), backfillDelayMs); + t.unref?.(); + } + } + + // Copies cached before hashing existed get their sha256 in the background, + // one at a time, so their content ids can be admitted too. + async function backfillHashes() { + let n = 0; + for (const r of await db.listMedia()) { + if (r.status !== 'ready' || r.sha256) continue; + const p = fileFor(r.video_id, r.gen); + if (!existsSync(p)) continue; + try { + const sha256 = await hashFile(p); + const cur = await db.getMedia(r.video_id); + if (!cur || cur.status !== 'ready' || cur.gen !== r.gen) continue; + await db.upsertMedia(r.video_id, { sha256 }); + notifyReady(r.video_id, r.gen, sha256, + { size: sizeOf(p), height: r.height, vcodec: r.vcodec, acodec: r.acodec, duration: r.duration }, r.meta); + n++; + } catch (e) { + log.warn?.(`[media] hash backfill ${r.video_id}: ${e.message}`); + } + } + return n; } - return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId }; + return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes }; }