82 lines
4.0 KiB
Diff
82 lines
4.0 KiB
Diff
--- a/server/media-cache.js
|
|
+++ b/server/media-cache.js
|
|
@@ -522,6 +522,42 @@
|
|
}
|
|
|
|
// The "Broken" button: drop the copy and fetch it again at high priority.
|
|
+ // Promote a file that came from somewhere else (P2P intake / rehydrate from
|
|
+ // a device) as this video's server copy. The caller has already hashed it
|
|
+ // and run validateMedia(). The bytes are kept EXACTLY (no remux — the cid
|
|
+ // must stay true); only the audio sidecar is derived. Never replaces an
|
|
+ // existing ready copy or races a running fetch.
|
|
+ async function adoptFile(id, src, { sha256, probe, meta = {} }) {
|
|
+ if (!isMediaId(id)) throw new Error('adopt: bad video id');
|
|
+ const cur = await db.getMedia(id);
|
|
+ if (cur && cur.status === 'ready' && readyFilesExist(cur)) return { adopted: false, reason: 'already cached' };
|
|
+ if (jobs.has(id)) return { adopted: false, reason: 'fetch in progress' };
|
|
+ await makeRoom(id, probe.size);
|
|
+ const prefix = `${id}-${now()}-adopt`;
|
|
+ const m4a = join(tmpDir, `${prefix}.m4a`);
|
|
+ try {
|
|
+ const ex = await run(ffmpeg, ['-v', 'error', '-nostdin', '-y', '-i', src,
|
|
+ '-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 gen = ((cur && cur.gen) || 0) + 1;
|
|
+ try { renameSync(src, fileFor(id, gen)); } catch { copyFileSync(src, fileFor(id, gen)); unlinkSync(src); }
|
|
+ renameSync(m4a, fileFor(id, gen, 'm4a'));
|
|
+ const t = now();
|
|
+ await db.upsertMedia(id, {
|
|
+ status: 'ready', gen, size: probe.size + sizeOf(fileFor(id, gen, 'm4a')),
|
|
+ height: probe.height, vcodec: probe.vcodec, acodec: probe.acodec, duration: probe.duration,
|
|
+ optimized: OPT_REV, // re-encoding would change the bytes other devices hold
|
|
+ meta: JSON.stringify(meta), priority: HIGH, auto: 0, attempts: 0, error: null, retry_at: 0,
|
|
+ created_at: (cur && cur.created_at) || t, updated_at: t, last_access: t, sha256,
|
|
+ });
|
|
+ removeFiles(id, gen);
|
|
+ log.info?.(`[media] adopted ${id} ${probe.height}p ${fmtMB(probe.size)} from P2P`);
|
|
+ return { adopted: true, gen };
|
|
+ } finally {
|
|
+ sweepTmp(prefix);
|
|
+ }
|
|
+ }
|
|
+
|
|
async function redownload(id) {
|
|
if (!isMediaId(id)) throw new MediaSkip('invalid video id');
|
|
const existing = jobs.get(id);
|
|
@@ -676,5 +712,5 @@
|
|
return n;
|
|
}
|
|
|
|
- return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes };
|
|
+ return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile };
|
|
}
|
|
--- a/server/media-cache.test.js
|
|
+++ b/server/media-cache.test.js
|
|
@@ -261,6 +261,26 @@
|
|
expect(await dbmod.getMedia('retAAAAAAA1')).not.toBeNull();
|
|
});
|
|
|
|
+ test('adoptFile promotes a validated outside file byte-for-byte, once', async () => {
|
|
+ await clearDb();
|
|
+ const { cache, dir, calls } = makeCache();
|
|
+ await cache.init();
|
|
+ const src = join(root, 'adopt-src.mp4');
|
|
+ copyFileSync(fx('good-fs.mp4'), src);
|
|
+ const bytes = readFileSync(src);
|
|
+ const probe = await validateMedia(src, 0);
|
|
+ const r = await cache.adoptFile('adoptAAAAA1', src, { sha256: 'e'.repeat(64), probe, meta: { title: 'from a device' } });
|
|
+ expect(r.adopted).toBe(true);
|
|
+ const row = await cache.getReady('adoptAAAAA1');
|
|
+ expect(row.sha256).toBe('e'.repeat(64));
|
|
+ expect(row.optimized).toBe(OPT_REV);
|
|
+ expect(readFileSync(join(dir, `adoptAAAAA1.${row.gen}.mp4`)).equals(bytes)).toBe(true);
|
|
+ expect(existsSync(join(dir, `adoptAAAAA1.${row.gen}.m4a`))).toBe(true);
|
|
+ expect(calls.length).toBe(0); // nothing fetched from the source
|
|
+ copyFileSync(fx('good-fs.mp4'), src);
|
|
+ expect((await cache.adoptFile('adoptAAAAA1', src, { sha256: 'f'.repeat(64), probe })).adopted).toBe(false);
|
|
+ });
|
|
+
|
|
test('free-disk guard skips caching', async () => {
|
|
await clearDb();
|
|
const { cache, calls } = makeCache({ freeBytes: () => 1024 ** 3, minFreeBytes: 5 * 1024 ** 3 });
|