Hash every validated server copy and register it as verified content

This commit is contained in:
Claude
2026-09-30 07:18:52 +00:00
parent b19193841c
commit ea56d286b3
10 changed files with 245 additions and 7 deletions

View File

@@ -24,6 +24,7 @@ import {
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 @@ export function createMediaCache({
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 @@ export function createMediaCache({
}
}
// 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 @@ export function createMediaCache({
'-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 @@ export function createMediaCache({
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 @@ export function createMediaCache({
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 @@ export function createMediaCache({
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 @@ export function createMediaCache({
}
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?.();
}
}
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId };
// 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, backfillHashes };
}