--- 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 });