Files
ytplayer/server/media-cache.test.js

283 lines
14 KiB
JavaScript

// bun test media-cache.test.js — validation gate, job queue, eviction, redownload.
// Uses real ffmpeg fixtures and the real db.js against a throwaway DB file.
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, copyFileSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const root = mkdtempSync(join(tmpdir(), 'ytp-media-test-'));
process.env.DB_PATH = join(root, 'test.db');
const dbmod = await import('./db.js');
const { createMediaCache, validateMedia, MediaSkip, HIGH, LOW, OPT_REV } = await import('./media-cache.js');
const fx = (name) => join(root, name);
const ff = (...args) => {
const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args]);
if (r.status !== 0) throw new Error(String(r.stderr));
};
const silentLog = { info() {}, warn() {} };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) { if (await fn()) return true; await sleep(50); }
return false;
}
beforeAll(async () => {
await dbmod.initDb();
// 10 s 640x360 H.264 + AAC — the shape of a good cached copy.
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', fx('good.mp4'));
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10',
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', fx('noaudio.mp4'));
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
'-c:v', 'mpeg4', '-c:a', 'aac', '-shortest', fx('mpeg4.mp4'));
// HEVC as the compression lane writes it (hvc1) and as Safari rejects it (hev1).
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
'-c:v', 'libx265', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-tag:v', 'hvc1', '-x265-params', 'log-level=error',
'-c:a', 'aac', '-shortest', fx('hevc.mp4'));
ff('-i', fx('hevc.mp4'), '-c', 'copy', '-tag:v', 'hev1', fx('hevc-hev1.mp4'));
const buf = readFileSync(fx('good.mp4'));
writeFileSync(fx('truncated.mp4'), buf.subarray(0, Math.floor(buf.length * 0.6)));
// The nasty case: moov up front (index says 10 s) but the media data is cut.
ff('-i', fx('good.mp4'), '-c', 'copy', '-movflags', '+faststart', fx('good-fs.mp4'));
const fs = readFileSync(fx('good-fs.mp4'));
writeFileSync(fx('truncated-fs.mp4'), fs.subarray(0, Math.floor(fs.length * 0.6)));
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
describe('validateMedia', () => {
test('accepts a good H.264 + AAC file', async () => {
const p = await validateMedia(fx('good.mp4'), 10);
expect(p.vcodec).toBe('h264');
expect(p.acodec).toBe('aac');
expect(p.height).toBe(360);
});
test('rejects a truncated file', async () => {
await expect(validateMedia(fx('truncated.mp4'), 10)).rejects.toThrow(/validation/);
});
test('rejects a faststart file whose media data is cut short', async () => {
await expect(validateMedia(fx('truncated-fs.mp4'), 10)).rejects.toThrow(/validation/);
});
test('rejects a file without audio', async () => {
await expect(validateMedia(fx('noaudio.mp4'), 10)).rejects.toThrow(/AAC/);
});
test('rejects a non-h264 file', async () => {
await expect(validateMedia(fx('mpeg4.mp4'), 10)).rejects.toThrow(/not h264/);
});
test('accepts hvc1-tagged HEVC for the compression lane, but not as a download', async () => {
const p = await validateMedia(fx('hevc.mp4'), 10);
expect(p.vcodec).toBe('hevc');
await expect(validateMedia(fx('hevc.mp4'), 10, { codecs: ['h264'] })).rejects.toThrow(/not h264/);
});
test('rejects hev1-tagged HEVC (Safari refuses it)', async () => {
await expect(validateMedia(fx('hevc-hev1.mp4'), 10)).rejects.toThrow(/hvc1/);
});
test('rejects a file shorter than the source video', async () => {
await expect(validateMedia(fx('good.mp4'), 60)).rejects.toThrow(/truncated/);
});
});
let dirN = 0;
function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
const dir = join(root, 'media' + dirN++);
const calls = [];
const db = {
getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia,
mediaStats: dbmod.mediaStats,
};
const state = { fixture };
const cache = createMediaCache({
dir, db, log: silentLog,
getInfo: async (id) => ({ id, title: 'T ' + id, channel: 'C', duration }),
download: async (id, out) => { calls.push(id); await sleep(30); copyFileSync(fx(state.fixture), out); return out; },
transcode: { enabled: false },
freeBytes: () => 100 * 1024 ** 3,
...opts,
});
return { cache, dir, calls, state };
}
async function clearDb() {
for (const r of await dbmod.listMedia()) await dbmod.deleteMedia(r.video_id);
}
describe('media cache jobs', () => {
test('fetches once, dedupes concurrent requests, then serves from disk', async () => {
await clearDb();
const { cache, dir, calls } = makeCache();
await cache.init();
const [a, b] = await Promise.all([
cache.ensureCached('vidAAAAAAA1', { priority: LOW, auto: true }),
cache.ensureCached('vidAAAAAAA1', { priority: HIGH }),
]);
expect(calls).toEqual(['vidAAAAAAA1']);
expect(a.status).toBe('ready');
expect(b.gen).toBe(a.gen);
expect(existsSync(join(dir, `vidAAAAAAA1.${a.gen}.mp4`))).toBe(true);
expect(existsSync(join(dir, `vidAAAAAAA1.${a.gen}.m4a`))).toBe(true);
expect(await cache.getReady('vidAAAAAAA1')).not.toBeNull();
await cache.ensureCached('vidAAAAAAA1');
expect(calls.length).toBe(1);
expect(JSON.parse((await cache.getReady('vidAAAAAAA1')).meta).title).toBe('T vidAAAAAAA1');
});
test('HIGH request jumps ahead of queued LOW jobs', async () => {
await clearDb();
const { cache, calls } = makeCache();
await cache.init();
const p = [
cache.ensureCached('lowAAAAAAA1', { auto: true }),
cache.ensureCached('lowAAAAAAA2', { auto: true }),
cache.ensureCached('lowAAAAAAA3', { auto: true }),
cache.ensureCached('highAAAAAA1', { priority: HIGH }),
];
await Promise.all(p);
expect(calls[0]).toBe('lowAAAAAAA1'); // already running when HIGH arrived
expect(calls[1]).toBe('highAAAAAA1');
});
test('a broken download is never cached and backs off', async () => {
await clearDb();
const { cache, dir, calls } = makeCache({ fixture: 'truncated.mp4' });
await cache.init();
await expect(cache.ensureCached('brokenAAAA1', { priority: HIGH })).rejects.toThrow(/validation|remux/);
const row = await dbmod.getMedia('brokenAAAA1');
expect(row.status).toBe('failed');
expect(row.retry_at).toBeGreaterThan(Date.now());
expect(readdirSync(dir).filter((n) => n !== '.tmp')).toEqual([]);
expect(readdirSync(join(dir, '.tmp'))).toEqual([]);
await expect(cache.ensureCached('brokenAAAA1', { priority: HIGH })).rejects.toMatchObject({ code: 'BACKOFF' });
expect(calls.length).toBe(1);
});
test('a faststart download with cut media data is rejected too', async () => {
await clearDb();
const { cache, dir } = makeCache({ fixture: 'truncated-fs.mp4' });
await cache.init();
await expect(cache.ensureCached('brokenAAAA2', { priority: HIGH })).rejects.toThrow();
expect((await dbmod.getMedia('brokenAAAA2')).status).toBe('failed');
expect(readdirSync(dir).filter((n) => n !== '.tmp')).toEqual([]);
});
test('redownload drops the copy and fetches a fresh one', async () => {
await clearDb();
const { cache, dir, calls } = makeCache();
await cache.init();
const first = await cache.ensureCached('redoAAAAAA1', { priority: HIGH });
const res = await cache.redownload('redoAAAAAA1');
expect(res.status).toBe('queued');
expect(existsSync(join(dir, `redoAAAAAA1.${first.gen}.mp4`))).toBe(false);
expect(await cache.getReady('redoAAAAAA1')).toBeNull();
expect(await until(async () => !!(await cache.getReady('redoAAAAAA1')))).toBe(true);
const second = await cache.getReady('redoAAAAAA1');
expect(second.gen).toBeGreaterThan(first.gen);
expect(calls.length).toBe(2);
});
test('auto jobs respect the auto length limit; explicit saves do not', async () => {
await clearDb();
const { cache } = makeCache({ duration: 7200, autoMaxSeconds: 3600 });
await cache.init();
await expect(cache.ensureCached('longAAAAAA1', { auto: true })).rejects.toMatchObject({ code: 'SKIPPED' });
expect(await dbmod.getMedia('longAAAAAA1')).toBeNull();
});
test('evicts least-recently-played copies to stay under the budget', async () => {
await clearDb();
const size = readFileSync(fx('good.mp4')).length;
// Budget fits ~2 copies (+ sidecars) but not 3 once the new-download estimate is added.
const { cache } = makeCache({ maxBytes: size * 2 + 60 * 250 * 1024 + 50_000, duration: 10 });
await cache.init();
await cache.ensureCached('lruAAAAAAA1', { priority: HIGH });
await cache.ensureCached('lruAAAAAAA2', { priority: HIGH });
// Age both beyond the eviction protection window; #1 is the older one.
await dbmod.upsertMedia('lruAAAAAAA1', { last_access: 1000 });
await dbmod.upsertMedia('lruAAAAAAA2', { last_access: 2000 });
await cache.ensureCached('lruAAAAAAA3', { priority: HIGH });
expect(await dbmod.getMedia('lruAAAAAAA1')).toBeNull();
expect(await cache.getReady('lruAAAAAAA3')).not.toBeNull();
});
test('free-disk guard skips caching', async () => {
await clearDb();
const { cache, calls } = makeCache({ freeBytes: () => 1024 ** 3, minFreeBytes: 5 * 1024 ** 3 });
await cache.init();
await expect(cache.ensureCached('diskAAAAAA1', { priority: HIGH })).rejects.toMatchObject({ code: 'SKIPPED' });
expect(calls.length).toBe(0);
});
test('boot resumes a job left queued and sweeps stray files', async () => {
await clearDb();
const { cache, dir, calls } = makeCache();
await dbmod.upsertMedia('bootAAAAAA1', { status: 'downloading', priority: HIGH });
writeFileSync(join(dir + '_never'), '');
const { mkdirSync } = await import('node:fs');
mkdirSync(join(dir, '.tmp'), { recursive: true });
writeFileSync(join(dir, '.tmp', 'partial.mp4.part'), 'x');
writeFileSync(join(dir, 'orphanAAAA1.3.mp4'), 'x');
await cache.init();
expect(existsSync(join(dir, 'orphanAAAA1.3.mp4'))).toBe(false);
expect(await until(async () => !!(await cache.getReady('bootAAAAAA1')))).toBe(true);
expect(calls).toEqual(['bootAAAAAA1']);
});
test('HEVC compression lane swaps in a smaller copy under a new gen', async () => {
await clearDb();
const { cache } = makeCache({ transcode: { enabled: true, codec: 'hevc', crf: 34, preset: 'ultrafast', threads: 2 } });
await cache.init();
const first = await cache.ensureCached('optAAAAAAA1', { priority: HIGH });
expect(first.vcodec).toBe('h264');
expect(await until(async () => (await dbmod.getMedia('optAAAAAAA1')).optimized === OPT_REV)).toBe(true);
const after = await dbmod.getMedia('optAAAAAAA1');
expect(after.gen).toBeGreaterThan(first.gen);
expect(after.vcodec).toBe('hevc');
expect(after.size).toBeLessThan(first.size);
// old generation is kept for in-flight playback (grace period)
expect(await cache.filePath('optAAAAAAA1', first.gen)).not.toBeNull();
expect(await cache.filePath('optAAAAAAA1', after.gen, 'm4a')).not.toBeNull();
// and the HEVC copy still passes the gate verify() uses
expect(await cache.verify('optAAAAAAA1')).toBe(true);
});
test('copies evaluated by an older lane revision are re-queued at boot', async () => {
await clearDb();
const seed = makeCache();
await seed.cache.init();
await seed.cache.ensureCached('revAAAAAAA1', { priority: HIGH });
await dbmod.upsertMedia('revAAAAAAA1', { optimized: 1 }); // the old x264 revision
// A fresh instance over the same dir + DB, as after a deploy.
const same = createMediaCache({
dir: seed.dir, db: { getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia, mediaStats: dbmod.mediaStats },
log: silentLog, getInfo: async () => ({ duration: 10 }), download: async () => { throw new Error('no download expected'); },
transcode: { enabled: true, codec: 'hevc', crf: 34, preset: 'ultrafast', threads: 2 }, freeBytes: () => 100 * 1024 ** 3,
});
await same.init();
expect(await until(async () => (await dbmod.getMedia('revAAAAAAA1')).optimized === OPT_REV)).toBe(true);
});
test('verify() drops and refetches a copy that went bad on disk, keeps a good one', async () => {
await clearDb();
let clock = Date.now();
const { cache, dir, calls } = makeCache({ now: () => clock });
await cache.init();
const row = await cache.ensureCached('verAAAAAAA1', { priority: HIGH });
expect(await cache.verify('verAAAAAAA1')).toBe(true);
writeFileSync(join(dir, `verAAAAAAA1.${row.gen}.mp4`), Buffer.alloc(300_000, 7));
expect(await cache.verify('verAAAAAAA1')).toBeNull(); // debounced for 10 min
clock += 11 * 60_000;
expect(await cache.verify('verAAAAAAA1')).toBe(false);
expect(await until(async () => !!(await cache.getReady('verAAAAAAA1')))).toBe(true);
expect((await cache.getReady('verAAAAAAA1')).gen).toBeGreaterThan(row.gen);
expect(calls.length).toBe(2);
});
test('rejects path-like ids', async () => {
const { cache } = makeCache();
await expect(cache.ensureCached('../../etc')).rejects.toBeInstanceOf(MediaSkip);
expect(await cache.filePath('..', 1)).toBeNull();
});
});