Compact HE-AAC audio saves: server lane with fdkaac, shrink saved songs from Saved videos

This commit is contained in:
Jonathan Sykes
2026-10-18 17:28:47 +08:00
parent eef9fbf05f
commit b48d36b3dd
12 changed files with 342 additions and 22 deletions

View File

@@ -168,6 +168,8 @@ function metaFromInfo(info, duration) {
export function createMediaCache({
dir, db, download, getInfo, getSourceUrl = id => `https://www.youtube.com/watch?v=${id}`,
ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', nice = 'nice',
fdkaac = 'fdkaac', // Fraunhofer encoder CLI for the compact (HE-AAC) audio lane
compact = { enabled: true, bitrate: 48000 },
maxBytes = 10 * 1024 ** 3,
minFreeBytes = 5 * 1024 ** 3,
autoMaxSeconds = 3 * 3600,
@@ -203,6 +205,9 @@ export function createMediaCache({
const stillQueue = []; // copies whose still-picture check hasn't run yet
const stillChecks = new Map(); // videoId -> running check (dedupes the two lanes)
let stillActive = false;
const heQueue = []; // copies waiting for their compact HE-AAC audio
let heActive = null;
let heMissing = false; // fdkaac isn't installed: the lane stays off
const lastTouch = new Map();
function sweepTmp(prefix) {
@@ -219,7 +224,7 @@ export function createMediaCache({
let names = [];
try { names = readdirSync(dir); } catch { return; }
for (const n of names) {
const m = /^(.+)\.(\d+)\.(mp4|m4a|jpg)$/.exec(n);
const m = /^(.+)\.(\d+)\.(mp4|m4a|jpg|he\.m4a)$/.exec(n);
if (!m || m[1] !== id || (keepGen !== null && Number(m[2]) === keepGen)) continue;
const p = join(dir, n);
const rm = () => { try { unlinkSync(p); } catch { /* gone */ } };
@@ -363,7 +368,7 @@ export function createMediaCache({
status: 'ready', gen,
size: probe.size + sizeOf(fileFor(id, gen, 'm4a')),
height: probe.height, vcodec: probe.vcodec, acodec: probe.acodec, duration: probe.duration,
optimized: 0, still: null, audio_sha256: null, meta: JSON.stringify(metaFromInfo(info, probe.duration)),
optimized: 0, still: null, audio_sha256: null, he_size: null, he_sha256: null, meta: JSON.stringify(metaFromInfo(info, probe.duration)),
attempts: 0, error: null, retry_at: 0, updated_at: t, last_access: t, sha256,
};
await db.upsertMedia(id, fields);
@@ -371,6 +376,7 @@ export function createMediaCache({
job.status = 'ready';
log.info?.(`[media] cached ${id} ${probe.height}p ${fmtMB(fields.size)}`);
queueStill(id);
queueHe(id);
enqueueOptimize(id);
notifyReady(id, gen, sha256, probe, fields.meta);
return { ...(prev || {}), video_id: id, ...fields };
@@ -498,6 +504,8 @@ export function createMediaCache({
const oldAudio = fileFor(id, row.gen, 'm4a');
const newAudio = fileFor(id, gen, 'm4a');
try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); }
const oldHe = fileFor(id, row.gen, 'he.m4a');
if (existsSync(oldHe)) { try { linkSync(oldHe, fileFor(id, gen, 'he.m4a')); } catch { copyFileSync(oldHe, fileFor(id, gen, 'he.m4a')); } }
const oldArt = fileFor(id, row.gen, 'jpg');
if (existsSync(oldArt)) { try { linkSync(oldArt, fileFor(id, gen, 'jpg')); } catch { copyFileSync(oldArt, fileFor(id, gen, 'jpg')); } }
await db.upsertMedia(id, {
@@ -568,6 +576,94 @@ export function createMediaCache({
return p;
}
// Compact audio lane: the m4a re-encoded to HE-AAC v1 (AAC-LC core + SBR) at
// ~48 kbps, about a third of YouTube's ~128 kbps AAC-LC, for phones that
// save songs as audio. Every browser, iOS and Android decode HE-AAC. ffmpeg's
// own AAC encoder only does LC, so this needs fdkaac (Debian contrib).
// One at a time, niced; a device asking for one jumps the queue.
function queueHe(id, urgent = false) {
if (!compact || !compact.enabled || heMissing || heActive === id) return;
const i = heQueue.indexOf(id);
if (i >= 0) { if (urgent && i > 0) { heQueue.splice(i, 1); heQueue.unshift(id); } }
else if (urgent) heQueue.unshift(id);
else heQueue.push(id);
pumpHe();
}
function pumpHe() {
if (heActive || !heQueue.length) return;
const id = heQueue.shift();
heActive = id;
makeHe(id)
.catch((err) => log.warn?.(`[media] compact audio ${id} failed: ${err.message}`))
.finally(() => { heActive = null; pumpHe(); });
}
async function makeHe(id) {
const row = await db.getMedia(id);
if (!row || row.status !== 'ready' || (row.he_size !== null && row.he_size !== undefined)) return;
const src = fileFor(id, row.gen, 'm4a');
if (!existsSync(src)) return;
const prefix = `${id}-${now()}-he`;
const raw = join(tmpDir, `${prefix}.raw.m4a`);
const out = join(tmpDir, `${prefix}.m4a`);
const fail = async (why) => {
const cur = await db.getMedia(id);
if (cur && cur.status === 'ready' && cur.gen === row.gen) await db.upsertMedia(id, { he_size: 0 });
log.warn?.(`[media] compact audio ${id}: ${why}`);
};
try {
// Decoded PCM goes straight into the encoder (no temp WAV on the cache drive).
const sh = ['-c', `"$0" -v error -nostdin -i "$1" -f wav - | "$2" -S -p 5 -b ${Number(compact.bitrate) || 48000} -o "$3" -`,
ffmpeg, src, fdkaac, raw];
const r = nice ? await run(nice, ['-n', '19', 'sh', ...sh]) : await run('sh', sh);
if (r.code === 127 || /fdkaac.*not found/i.test(r.stderr)) {
heMissing = true;
heQueue.length = 0;
log.warn?.('[media] fdkaac is not installed — compact (HE-AAC) audio is off');
return;
}
if (r.code !== 0 || sizeOf(raw) < 1024) return fail('encode failed: ' + tail(r.stderr));
const m = await run(ffmpeg, ['-v', 'error', '-nostdin', '-y', '-i', raw, '-map', '0:a:0', '-c', 'copy',
'-movflags', '+faststart', '-f', 'mp4', out]);
if (m.code !== 0) return fail('remux failed: ' + tail(m.stderr));
// Gate: one AAC stream, as long as the source, decodes start to end cleanly.
const pr = await run(ffprobe, ['-v', 'error', '-show_entries', 'stream=codec_type,codec_name:format=duration',
'-of', 'json', out]);
let info = {};
try { info = JSON.parse(pr.stdout || '{}'); } catch { /* checked below */ }
const streams = info.streams || [];
const dur = Number(info.format && info.format.duration) || 0;
if (streams.length !== 1 || streams[0].codec_name !== 'aac') return fail('unexpected streams');
if (row.duration && dur < row.duration - 1.5) return fail(`too short (${dur.toFixed(1)} of ${row.duration.toFixed(1)} s)`);
const dec = await run(ffmpeg, ['-v', 'error', '-nostdin', '-i', out, '-f', 'null', '-']);
if (dec.code !== 0 || String(dec.stderr).trim()) return fail('decode check failed: ' + tail(dec.stderr));
const size = sizeOf(out);
if (size > sizeOf(src) * (1 - MIN_SAVING)) return fail(`not smaller (${fmtMB(size)} vs ${fmtMB(sizeOf(src))})`);
const sha = await hashFile(out);
const cur = await db.getMedia(id);
if (!cur || cur.status !== 'ready' || cur.gen !== row.gen) return; // replaced meanwhile
renameSync(out, fileFor(id, row.gen, 'he.m4a'));
await db.upsertMedia(id, { he_size: size, he_sha256: sha });
log.info?.(`[media] compact audio ${id}: ${fmtMB(sizeOf(src))} → ${fmtMB(size)}`);
} finally {
sweepTmp(prefix);
}
}
// Where a device's compact-audio save stands. Asking for one that isn't
// made yet moves it to the front of the lane.
async function compactAudio(id) {
if (!isMediaId(id) || !compact || !compact.enabled || heMissing) return { status: 'off' };
const row = await getReady(id);
if (!row) return { status: 'none' };
if (row.he_size === 0) return { status: 'off' };
const p = fileFor(id, row.gen, 'he.m4a');
if (row.he_size > 0 && existsSync(p)) return { status: 'ready', gen: row.gen, path: p, size: sizeOf(p), sha256: row.he_sha256, row };
queueHe(id, true);
return { status: 'working' };
}
// Keyframes only: ~1 s for a 5-minute video. Every keyframe-to-keyframe
// scene score must stay under STILL_MAX_SCENE.
async function isStill(src) {
@@ -804,6 +900,8 @@ export function createMediaCache({
active: fetchActive ? fetchActive.id : null,
optimizing: optActive,
optimizeQueued: optQueue.length,
compactQueued: heQueue.length,
compactAudio: heMissing ? 'fdkaac missing' : (compact && compact.enabled ? 'on' : 'off'),
};
}
@@ -821,7 +919,7 @@ export function createMediaCache({
const byId = new Map(rows.map((r) => [r.video_id, r]));
for (const name of readdirSync(dir)) {
if (name === '.tmp') continue;
const m = /^(.+)\.(\d+)\.(mp4|m4a|jpg)$/.exec(name);
const m = /^(.+)\.(\d+)\.(mp4|m4a|jpg|he\.m4a)$/.exec(name);
const r = m && byId.get(m[1]);
if (!m || !r || r.status !== 'ready' || r.gen !== Number(m[2])) {
try { unlinkSync(join(dir, name)); } catch { /* gone */ }
@@ -832,6 +930,7 @@ export function createMediaCache({
if (r.status === 'ready') {
if (!readyFilesExist(r)) { await db.deleteMedia(r.video_id); removeFiles(r.video_id); continue; }
if (r.still === null || r.still === undefined) queueStill(r.video_id);
if (r.he_size === null || r.he_size === undefined) queueHe(r.video_id);
if ((r.optimized || 0) < OPT_REV) enqueueOptimize(r.video_id);
} else if (r.status === 'queued' || r.status === 'downloading' || r.status === 'validating') {
startJob(r.video_id, r.priority === HIGH ? HIGH : LOW, !!r.auto, r);
@@ -869,6 +968,6 @@ export function createMediaCache({
return n;
}
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available,
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available, compactAudio,
evictNow: id => evict(id, 'admin') };
}