Compact HE-AAC audio saves: server lane with fdkaac, shrink saved songs from Saved videos
This commit is contained in:
@@ -293,7 +293,8 @@ export async function initDb() {
|
||||
try { await db.execute('ALTER TABLE media_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0'); } catch { /* already migrated */ }
|
||||
// Still-picture songs: still = NULL (not checked yet) | 0 | 1. A still copy
|
||||
// also has <id>.<gen>.jpg, and devices may save just its m4a (audio_sha256).
|
||||
for (const col of ['still INTEGER', 'audio_sha256 TEXT']) {
|
||||
// he_size: NULL = no compact (HE-AAC) audio yet, 0 = could not make one.
|
||||
for (const col of ['still INTEGER', 'audio_sha256 TEXT', 'he_size INTEGER', 'he_sha256 TEXT']) {
|
||||
try { await db.execute(`ALTER TABLE media_cache ADD COLUMN ${col}`); } catch { /* already migrated */ }
|
||||
}
|
||||
// Uploads shared from devices (PWA share target): who sent it, and whether it
|
||||
@@ -680,7 +681,7 @@ const MEDIA_COLS = new Set([
|
||||
'status', 'gen', 'size', 'height', 'vcodec', 'acodec', 'duration', 'optimized',
|
||||
'meta', 'priority', 'auto', 'attempts', 'error', 'retry_at', 'created_at',
|
||||
'updated_at', 'last_access', 'hits', 'sha256',
|
||||
'pinned', 'still', 'audio_sha256',
|
||||
'pinned', 'still', 'audio_sha256', 'he_size', 'he_sha256',
|
||||
]);
|
||||
|
||||
function rowToObj(res, row) {
|
||||
|
||||
@@ -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') };
|
||||
}
|
||||
|
||||
@@ -435,6 +435,39 @@ describe('media cache jobs', () => {
|
||||
expect(await again.filePath('stilAAAAAA1', after.gen, 'jpg')).toBeTruthy();
|
||||
}, 90000);
|
||||
|
||||
// Stand-in for fdkaac (not installed on dev machines): same CLI shape, reads
|
||||
// WAV on stdin, writes a low-bitrate AAC .m4a to -o.
|
||||
function fakeFdkaac() {
|
||||
const p = join(root, 'fake-fdkaac.sh');
|
||||
writeFileSync(p, '#!/bin/sh\nwhile [ "$1" != "-o" ]; do shift; done\nout="$2"\nexec ffmpeg -v error -nostdin -y -f wav -i - -c:a aac -b:a 24k -f mp4 "$out"\n');
|
||||
spawnSync('chmod', ['+x', p]);
|
||||
return p;
|
||||
}
|
||||
|
||||
test('compact audio lane: HE-AAC copy is made once, gated, and kept with the copy', async () => {
|
||||
await clearDb();
|
||||
const { cache } = makeCache({ fixture: 'still40.mp4', duration: 40, fdkaac: fakeFdkaac() });
|
||||
await cache.init();
|
||||
expect((await cache.compactAudio('compAAAAAA1')).status).toBe('none');
|
||||
await cache.ensureCached('compAAAAAA1', { priority: HIGH });
|
||||
expect(await until(async () => ((await dbmod.getMedia('compAAAAAA1')).he_size || 0) > 0, 20000)).toBe(true);
|
||||
const he = await cache.compactAudio('compAAAAAA1');
|
||||
expect(he.status).toBe('ready');
|
||||
const lc = await cache.filePath('compAAAAAA1', he.gen, 'm4a');
|
||||
expect(he.size).toBeLessThan(readFileSync(lc).length * 0.85);
|
||||
expect(he.sha256).toBe(await sha256File(he.path));
|
||||
expect(await cache.filePath('compAAAAAA1', he.gen, 'he.m4a')).toBe(he.path);
|
||||
}, 60000);
|
||||
|
||||
test('compact audio is off (never blocks saves) when fdkaac is missing', async () => {
|
||||
await clearDb();
|
||||
const { cache } = makeCache({ fdkaac: join(root, 'no-such-fdkaac') });
|
||||
await cache.init();
|
||||
await cache.ensureCached('nofdAAAAAA1', { priority: HIGH });
|
||||
expect(await until(async () => (await cache.compactAudio('nofdAAAAAA1')).status === 'off', 15000)).toBe(true);
|
||||
expect((await cache.stats()).compactAudio).toBe('fdkaac missing');
|
||||
}, 30000);
|
||||
|
||||
test('a moving copy already done by the HEVC lane is not re-encoded for rev 3', async () => {
|
||||
await clearDb();
|
||||
const seed = makeCache({ fixture: 'fat64.mp4', duration: 64,
|
||||
|
||||
@@ -1348,6 +1348,9 @@ const media = createMediaCache({
|
||||
},
|
||||
ffmpeg: FFMPEG,
|
||||
ffprobe: process.env.FFPROBE_PATH || 'ffprobe',
|
||||
// Compact HE-AAC audio for phone saves (?audio=he). MEDIA_COMPACT_AUDIO=0 turns it off.
|
||||
fdkaac: process.env.FDKAAC_PATH || 'fdkaac',
|
||||
compact: { enabled: process.env.MEDIA_COMPACT_AUDIO !== '0', bitrate: envNum('MEDIA_COMPACT_BITRATE', 48000) },
|
||||
maxBytes: envNum('MEDIA_CACHE_MAX_BYTES', 10 * 1024 ** 3),
|
||||
minFreeBytes: envNum('MEDIA_MIN_FREE_BYTES', 5 * 1024 ** 3),
|
||||
autoMaxSeconds: envNum('MEDIA_AUTO_MAX_SECONDS', 3 * 3600),
|
||||
@@ -1738,6 +1741,20 @@ registerDownloadRetryRoute(app, {
|
||||
media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
|
||||
});
|
||||
const DOWNLOAD_EXPOSE = 'X-Content-SHA256, Content-Range, Content-Length, ETag, Accept-Ranges';
|
||||
function compactDownloadResponse(c, videoId, fp, he) {
|
||||
if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
||||
media.touch(videoId);
|
||||
return rangeFileResponse(c, he.path, 'audio/mp4', 'no-store', {
|
||||
etag: `"${videoId}.${he.gen}.he"`,
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${videoId}.m4a"`,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Expose-Headers': DOWNLOAD_EXPOSE,
|
||||
...(he.sha256 ? { 'X-Content-SHA256': he.sha256 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cachedDownloadResponse(c, videoId, fp, row) {
|
||||
if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
||||
media.touch(videoId);
|
||||
@@ -1797,6 +1814,20 @@ app.get('/api/download/:videoId/prepare', async (c) => {
|
||||
if (ready) {
|
||||
// ?still=1: the device would rather keep the audio + picture of an
|
||||
// album-art song than a video of one unchanging frame.
|
||||
const stillArt = stillAudioPath(videoId, ready) ? { still: true, art: `/api/media/${videoId}/art?g=${ready.gen}` } : {};
|
||||
// ?audio=he: the phone saves the compact HE-AAC audio (~48 kbps) instead.
|
||||
// It is made once on the server and kept with the copy; until it exists
|
||||
// the device polls like for any other preparing save.
|
||||
if (c.req.query('audio') === 'he') {
|
||||
const he = await media.compactAudio(videoId);
|
||||
if (he.status === 'ready') {
|
||||
media.touch(videoId);
|
||||
return c.json({ ok: true, state: 'ready', gen: he.gen, size: he.size, sha256: he.sha256 || null,
|
||||
etag: `"${videoId}.${he.gen}.he"`, ext: 'm4a', compact: true, ...stillArt }, 200, nocache);
|
||||
}
|
||||
if (he.status === 'working') return c.json({ ok: true, state: 'working', status: 'compressing' }, 200, nocache);
|
||||
// 'off' (no encoder, or this song didn't shrink): the normal answer below.
|
||||
}
|
||||
const stillAudio = c.req.query('still') === '1' ? stillAudioPath(videoId, ready) : null;
|
||||
if (stillAudio) {
|
||||
media.touch(videoId);
|
||||
@@ -1879,6 +1910,10 @@ app.get('/api/download/:videoId', async (c) => {
|
||||
let cacheErr = null;
|
||||
try {
|
||||
const row = await media.ensureCached(videoId, { priority: HIGH });
|
||||
if (c.req.query('audio') === 'he') {
|
||||
const he = await media.compactAudio(videoId);
|
||||
if (he.status === 'ready') return compactDownloadResponse(c, videoId, fp, he);
|
||||
}
|
||||
// A device that can't decode HEVC must not save the HEVC copy — give it
|
||||
// the legacy H.264 save instead (the ?mux=1 path included).
|
||||
if (servableTo(row, c.req.query('hevc') === '1') || stillAudioPath(videoId, row) && c.req.query('still') === '1') {
|
||||
|
||||
Reference in New Issue
Block a user