Hash every validated server copy and register it as verified content
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user