121 lines
5.2 KiB
Diff
121 lines
5.2 KiB
Diff
--- 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 };
|
|
}
|