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

@@ -128,8 +128,19 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
- **Broken**: `POST /api/media/:id/redownload` (now-playing ⚠ Broken button). When a - **Broken**: `POST /api/media/:id/redownload` (now-playing ⚠ Broken button). When a
client falls back from a cached copy (`/api/streams?nocache=1`) the server also client falls back from a cached copy (`/api/streams?nocache=1`) the server also
re-validates its copy and refetches it if it fails — never trusts one device's error. re-validates its copy and refetches it if it fails — never trusts one device's error.
- Ops: `curl https://worship.hesed.sbs/api/media/stats`; files under - **Still-picture songs**: a fast still-check lane (keyframe scene scores, or 1 fps =
`/app/data/media` in the container. our own looped encode) marks `still=1`, writes `<id>.<gen>.jpg` + `audio_sha256`. Saves
send `?still=1` → the device stores `<id>.m4a` + OPFS `art/<id>.jpg` and plays it as
audio with the picture; online, still songs also play audio + `/api/media/:id/art`.
- **Compact audio (HE-AAC)**: `<id>.<gen>.he.m4a`, ~48 kbps HE-AAC v1 from **fdkaac**
(Debian contrib — ffmpeg's encoder is LC only), one niced lane, made for every copy and
moved to the front when a device asks (`prepare?audio=he` → `working` until ready).
Columns `he_size` (NULL = not made, 0 = failed/not smaller) + `he_sha256`. Devices ask
for it with Settings → "Save songs as compact audio" or Saved videos → Shrink
(`shrinkSaved` swaps the file in place; `ytpCompactSaves` remembers which ones).
Saved `.m4a` files of YouTube ids are never offered to P2P (not the server's file).
- Ops: `curl https://worship.hesed.sbs/api/media/stats` (`compactAudio`, `compactQueued`); files under
`$MEDIA_DIR` (`/app/bulk/media` on prod) in the container.
## Lyrics · chapters · bookmarks (`server/notes.js`, `frontend/lyrics-core.js`) ## Lyrics · chapters · bookmarks (`server/notes.js`, `frontend/lyrics-core.js`)
- **Shared per video** (every user sees the same copy): `video_notes` holds the live - **Shared per video** (every user sees the same copy): `video_notes` holds the live

View File

@@ -16,11 +16,15 @@ FROM oven/bun:1-debian
# ca-certificates for HTTPS fetches from yt-dlp # ca-certificates for HTTPS fetches from yt-dlp
# ffmpeg lets yt-dlp merge bestvideo+bestaudio into a single mp4 for the # ffmpeg lets yt-dlp merge bestvideo+bestaudio into a single mp4 for the
# "Save before playing" download path (GET /api/download?mux=1) # "Save before playing" download path (GET /api/download?mux=1)
RUN apt-get update -qq && \ # fdkaac (Debian contrib) encodes the compact HE-AAC audio phones can save;
# ffmpeg's built-in AAC encoder only does AAC-LC.
RUN sed -i 's/^Components: main$/Components: main contrib/' /etc/apt/sources.list.d/debian.sources && \
apt-get update -qq && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
curl \ curl \
python3 \ python3 \
ffmpeg \ ffmpeg \
fdkaac \
ca-certificates && \ ca-certificates && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*

View File

@@ -412,6 +412,19 @@ const SaveQueue = (() => {
// single-request save (HEVC copy this device can't play, too long for the // single-request save (HEVC copy this device can't play, too long for the
// server cache, no worker/OPFS sync access), otherwise the save result — // server cache, no worker/OPFS sync access), otherwise the save result —
// { ok:false, paused:true } means the partial is kept and will resume. // { ok:false, paused:true } means the partial is kept and will resume.
// Saves that hold the server's compact HE-AAC audio (this device only).
const CompactSaves = (() => {
const KEY = 'ytpCompactSaves';
const read = () => { try { return new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); } catch { return new Set(); } };
const write = (set) => { try { localStorage.setItem(KEY, JSON.stringify([...set])); } catch { /* storage blocked */ } };
return {
has: (id) => read().has(id),
mark(id) { const s = read(); if (!s.has(id)) { s.add(id); write(s); } },
drop(id) { const s = read(); if (s.delete(id)) write(s); },
clear() { write(new Set()); },
};
})();
async function resumableOpfsSave(videoId, qs, prepQs, onProgress, signal) { async function resumableOpfsSave(videoId, qs, prepQs, onProgress, signal) {
if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null; if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null;
const enc = encodeURIComponent(videoId); const enc = encodeURIComponent(videoId);
@@ -442,7 +455,8 @@ async function resumableOpfsSave(videoId, qs, prepQs, onProgress, signal) {
resumable: { etag: prep.etag, size: prep.size, sha256: prep.sha256 || null, ext: prep.ext || 'mp4' }, resumable: { etag: prep.etag, size: prep.size, sha256: prep.sha256 || null, ext: prep.ext || 'mp4' },
onProgress: (received, total, m) => report({ phase: m && m.verifying ? 'verifying' : 'downloading', received, total }), onProgress: (received, total, m) => report({ phase: m && m.verifying ? 'verifying' : 'downloading', received, total }),
}); });
if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 }; if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0,
ext: prep.ext || 'mp4', compact: !!prep.compact };
if (w.changed) continue; // the server copy was replaced — start over on the new one if (w.changed) continue; // the server copy was replaced — start over on the new one
if (w.paused) return { ok: false, paused: true, error: w.error || 'connection lost', received: w.received || 0 }; if (w.paused) return { ok: false, paused: true, error: w.error || 'connection lost', received: w.received || 0 };
if (w.fallback) return null; if (w.fallback) return null;
@@ -451,7 +465,7 @@ async function resumableOpfsSave(videoId, qs, prepQs, onProgress, signal) {
return { ok: false, error: 'the server copy kept changing — try again' }; return { ok: false, error: 'the server copy kept changing — try again' };
} }
async function opfsDownload(videoId, { mux = false, retry = false, onProgress = null, signal } = {}) { async function opfsDownload(videoId, { mux = false, retry = false, onProgress = null, signal, compact = false } = {}) {
if (!window.OPFS || !window.OPFS.isSupported()) { if (!window.OPFS || !window.OPFS.isSupported()) {
return { ok: false, error: 'OPFS not supported in this browser' }; return { ok: false, error: 'OPFS not supported in this browser' };
} }
@@ -465,6 +479,9 @@ async function opfsDownload(videoId, { mux = false, retry = false, onProgress =
if (hevcCapable()) params.set('hevc', '1'); if (hevcCapable()) params.set('hevc', '1');
// Album-art songs come back as audio + picture instead of a video of one frame. // Album-art songs come back as audio + picture instead of a video of one frame.
params.set('still', '1'); params.set('still', '1');
// Compact audio (setting, or Shrink in Saved videos): the server's HE-AAC copy.
const he = (compact || data.settings.compactAudio) && !isUploadId(videoId);
if (he) params.set('audio', 'he');
const qs = params.toString(); const qs = params.toString();
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`; const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
@@ -477,7 +494,11 @@ async function opfsDownload(videoId, { mux = false, retry = false, onProgress =
return { ok: false, paused: true, error: error.name === 'AbortError' ? 'Save paused' : 'Connection lost' }; return { ok: false, paused: true, error: error.name === 'AbortError' ? 'Save paused' : 'Connection lost' };
} }
} }
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1' ? 'hevc=1&still=1' : 'still=1', onProgress, signal); const prepParams = new URLSearchParams();
if (params.get('hevc') === '1') prepParams.set('hevc', '1');
prepParams.set('still', '1');
if (he) prepParams.set('audio', 'he');
const resumed = await resumableOpfsSave(videoId, qs, prepParams.toString(), onProgress, signal);
if (resumed) return resumed; if (resumed) return resumed;
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes, // Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
@@ -488,7 +509,7 @@ async function opfsDownload(videoId, { mux = false, retry = false, onProgress =
let workerError = null; let workerError = null;
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
const w = await window.OPFS.downloadVideo(videoId, url, { signal }); const w = await window.OPFS.downloadVideo(videoId, url, { signal });
if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 }; if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0, ext: w.ext || null };
if (w.paused) return w; if (w.paused) return w;
workerError = w.error || null; workerError = w.error || null;
} }
@@ -570,6 +591,7 @@ async function opfsList() {
async function opfsDelete(videoId) { async function opfsDelete(videoId) {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true }; if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ } try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ }
CompactSaves.drop(videoId);
if (window.DeviceDB) { await window.DeviceDB.deleteFile(videoId); if (window.P2PClient) window.P2PClient.changed(); } if (window.DeviceDB) { await window.DeviceDB.deleteFile(videoId); if (window.P2PClient) window.P2PClient.changed(); }
return { ok: true }; return { ok: true };
} }
@@ -577,6 +599,7 @@ async function opfsDelete(videoId) {
async function opfsClear() { async function opfsClear() {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true }; if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
try { await window.OPFS.clearAll(); } catch { /* ignore */ } try { await window.OPFS.clearAll(); } catch { /* ignore */ }
CompactSaves.clear();
if (window.DeviceDB) { await window.DeviceDB.clear(); if (window.P2PClient) window.P2PClient.changed(); } if (window.DeviceDB) { await window.DeviceDB.clear(); if (window.P2PClient) window.P2PClient.changed(); }
return { ok: true }; return { ok: true };
} }
@@ -674,6 +697,7 @@ const DEFAULT_SETTINGS = {
p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default
autoSavePlayed: true, // keep every video you play for offline (background save once playback starts) autoSavePlayed: true, // keep every video you play for offline (background save once playback starts)
saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming
compactAudio: false, // new saves are compact HE-AAC audio only (~⅓ of an audio save, no video)
repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends
loopOne: false, // repeat the single current video loopOne: false, // repeat the single current video
shuffle: false, // shuffle upcoming tracks when playing a list shuffle: false, // shuffle upcoming tracks when playing a list
@@ -1969,6 +1993,58 @@ async function saveStillArt(id) {
return true; return true;
} }
// Saved videos: what format a saved file is in.
function savedFormatLabel(it) {
if (isUploadId(it.id)) return 'Library file';
if (!/\.m4a$/i.test(it.name || '')) return 'Video';
return CompactSaves.has(it.id) ? 'Compact audio' : 'Audio';
}
function canShrinkSaved(it) {
const id = it && it.id;
return WEB && !!id && cachedIds.has(id) && !isUploadId(id) && !String(id).startsWith('edit_') && !CompactSaves.has(id);
}
// Swap a saved copy for the server's compact HE-AAC audio. The old file stays
// until the new one is complete, so a failed shrink loses nothing.
async function shrinkSaved(video) {
const id = video && video.id;
if (!canShrinkSaved({ id })) return { ok: false, error: 'nothing to shrink' };
if (downloading.has(id)) return { ok: false, error: 'it is still saving' };
if (current?.meta?.id === id && current.localUrl) return { ok: false, error: 'it is playing from that file — play something else first' };
const before = (await window.OPFS.getFileObject(id).catch(() => null))?.size || 0;
downloading.add(id);
let res;
try { res = await opfsDownload(id, { compact: true }); }
finally { downloading.delete(id); }
if (!res || !res.ok) return { ok: false, error: (res && res.error) || 'save failed' };
if (res.ext) await window.OPFS.keepOnly(id, `${id}.${res.ext}`);
if (res.compact) CompactSaves.mark(id);
await saveStillArt(id);
// An audio save is not the server's video file: stop offering it to other devices.
if (window.DeviceDB && /^m4a$/i.test(res.ext || '')) { await window.DeviceDB.deleteFile(id).catch(() => {}); window.P2PClient?.changed(); }
const after = (await window.OPFS.getFileObject(id).catch(() => null))?.size || 0;
return { ok: true, compact: !!res.compact, before, after };
}
// Shrink several saves one after another (the server converts each once).
let shrinkRunning = false;
async function shrinkSavedAll(ids) {
if (shrinkRunning) { toast('Already shrinking saved songs'); return; }
shrinkRunning = true;
let done = 0, saved = 0, failed = 0;
try {
for (const id of ids) {
if (current?.meta?.id === id) continue; // playing from that file right now
toast(`Shrinking saved songs… ${done + failed + 1}/${ids.length}`, { duration: 2500 });
const r = await shrinkSaved(videoById(id) || { id });
if (r.ok) { done++; saved += Math.max(0, r.before - r.after); } else failed++;
}
} finally { shrinkRunning = false; }
toast(`Shrunk ${done} song${done === 1 ? '' : 's'} · freed ${fmtBytes(saved)}${failed ? ` · ${failed} could not be shrunk` : ''}`, { duration: 6000 });
if (view.type === 'saved') renderSaved();
}
// Playback fields for a saved still-picture song: play it as audio and show // Playback fields for a saved still-picture song: play it as audio and show
// its saved picture. {} for anything else. // its saved picture. {} for anything else.
async function localStill(id) { async function localStill(id) {
@@ -2027,6 +2103,7 @@ async function preload(video, { quiet = false, mux = false, retry = false, direc
cacheMutations++; cacheMutations++;
// A still-picture song saved as audio isn't the server's video file, so // A still-picture song saved as audio isn't the server's video file, so
// it is never offered to other devices as one. // it is never offered to other devices as one.
if (res.compact) CompactSaves.mark(id); else CompactSaves.drop(id);
saveStillArt(id).then((still) => { if (!still) recordDeviceFile(id, res); }); saveStillArt(id).then((still) => { if (!still) recordDeviceFile(id, res); });
warmThumb(thumbUrlFor(id, video)); warmThumb(thumbUrlFor(id, video));
if (!quiet) toast(`Saved “${video.title}” ✓`); if (!quiet) toast(`Saved “${video.title}” ✓`);

View File

@@ -16,7 +16,7 @@ function harness({ autoPreload = true, cached = [], result = { ok: true, cached:
SaveSlots: { acquire: async () => {}, release() {} }, SaveSlots: { acquire: async () => {}, release() {} },
SaveQueue: { add: v => jobs.set(v.id, { status: 'queued' }), remove: id => jobs.delete(id), progress() {}, state: (id, status, message) => jobs.set(id, { status, message }) }, SaveQueue: { add: v => jobs.set(v.id, { status: 'queued' }), remove: id => jobs.delete(id), progress() {}, state: (id, status, message) => jobs.set(id, { status, message }) },
API: { cacheDownload: async id => { calls.push(id); if (error) throw error; return result; } }, API: { cacheDownload: async id => { calls.push(id); if (error) throw error; return result; } },
recordDeviceFile() {}, saveStillArt: async () => false, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0, recordDeviceFile() {}, saveStillArt: async () => false, CompactSaves: { mark() {}, drop() {} }, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0,
}); });
const preload = source.slice(source.indexOf('async function preload(video,'), source.indexOf('// A device copy that failed to play.')); const preload = source.slice(source.indexOf('async function preload(video,'), source.indexOf('// A device copy that failed to play.'));
const playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge')); const playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge'));

View File

@@ -218,7 +218,7 @@
opts.signal?.addEventListener('abort', abort, { once: true }); opts.signal?.addEventListener('abort', abort, { once: true });
worker.onmessage = (e) => { worker.onmessage = (e) => {
const m = e.data || {}; const m = e.data || {};
if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0 }); if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0, ext: m.ext || null });
else if (m.type === 'unsupported') finish({ ok: false, fallback: true }); else if (m.type === 'unsupported') finish({ ok: false, fallback: true });
else if (m.type === 'error') finish({ ok: false, error: m.error }); else if (m.type === 'error') finish({ ok: false, error: m.error });
else if (m.type === 'paused') finish({ ok: false, paused: true, received: m.received || 0, error: m.error }); else if (m.type === 'paused') finish({ ok: false, paused: true, received: m.received || 0, error: m.error });
@@ -359,6 +359,21 @@
return items.reduce((s, i) => s + i.size, 0); return items.reduce((s, i) => s + i.size, 0);
}, },
// Remove this video's other saved files (a different format), keeping
// `keepName` — after a save that replaced e.g. <id>.mp4 with <id>.m4a.
async keepOnly(videoId, keepName) {
try {
const dir = await getRoot();
const drop = [];
for await (const [name, handle] of dir.entries()) {
if (handle.kind !== 'file' || name === keepName || name.endsWith('.part') || name.endsWith('.part.json')) continue;
const dot = name.lastIndexOf('.');
if ((dot > -1 ? name.slice(0, dot) : name) === videoId) drop.push(name);
}
for (const name of drop) await dir.removeEntry(name).catch(() => {});
} catch { /* OPFS not available */ }
},
// Delete one video // Delete one video
async deleteVideo(videoId) { async deleteVideo(videoId) {
try { try {

View File

@@ -1,7 +1,7 @@
.saved-summary.saved-storage-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0 16px; } .saved-summary.saved-storage-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0 16px; }
.saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; } .saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; }
.saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; } .saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; }
html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; } html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) auto; align-items:center; gap:12px; min-height:88px; }
html[data-layout] #cards .saved-card .thumb { grid-column:1; grid-row:1; width:100%; min-width:0; max-width:100%; flex:none; overflow:hidden; } html[data-layout] #cards .saved-card .thumb { grid-column:1; grid-row:1; width:100%; min-width:0; max-width:100%; flex:none; overflow:hidden; }
html[data-layout] #cards .saved-card .thumb img { width:100%; max-width:100%; height:100%; object-fit:cover; } html[data-layout] #cards .saved-card .thumb img { width:100%; max-width:100%; height:100%; object-fit:cover; }
html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; } html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; }
@@ -14,4 +14,6 @@ html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex;
.saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); } .saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); }
.saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; } .saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
.saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; } .saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; }
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } } @media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) auto; } }
.saved-storage-summary .saved-shrink-all { margin-left:auto; }
@media(max-width:420px) { html[data-layout] .saved-file-actions { gap:4px; } html[data-layout] .saved-file-action { width:40px; min-width:40px; height:40px; padding:8px; } }

View File

@@ -4,7 +4,8 @@
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase()); const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
const paths = { const paths = {
export: '<use href="#media-download"/>', export: '<use href="#media-download"/>',
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>' delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>',
shrink: '<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/>'
}; };
function button(doc, kind, label, action) { function button(doc, kind, label, action) {
const b = doc.createElement('button'); b.type = 'button'; const b = doc.createElement('button'); b.type = 'button';
@@ -13,10 +14,14 @@
b.innerHTML = `<svg class="media-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`; b.innerHTML = `<svg class="media-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
b.onclick = e => { e.stopPropagation(); action(); }; return b; b.onclick = e => { e.stopPropagation(); action(); }; return b;
} }
function actions(row, video, exportFile, deleteFile) { function actions(row, video, exportFile, deleteFile, shrinkFile = null) {
const doc = row.ownerDocument, group = doc.createElement('div'); const doc = row.ownerDocument, group = doc.createElement('div');
group.className = 'saved-file-actions'; group.setAttribute('role', 'group'); group.className = 'saved-file-actions'; group.setAttribute('role', 'group');
group.setAttribute('aria-label', `File actions for ${video.title || video.id}`); group.setAttribute('aria-label', `File actions for ${video.title || video.id}`);
if (shrinkFile) {
const b = button(doc, 'shrink', `Shrink to compact audio: ${video.title || video.id}`, async () => { b.disabled = true; try { await shrinkFile(); } finally { b.disabled = false; } });
group.append(b);
}
group.append(button(doc, 'export', `Save to this device: ${video.title || video.id}`, exportFile), button(doc, 'delete', `Delete saved file: ${video.title || video.id}`, deleteFile)); group.append(button(doc, 'export', `Save to this device: ${video.title || video.id}`, exportFile), button(doc, 'delete', `Delete saved file: ${video.title || video.id}`, deleteFile));
row.append(group); row.append(group);
} }

View File

@@ -658,7 +658,27 @@ async function renderSaved() {
c.innerHTML = ''; c.innerHTML = '';
const summary = document.createElement('div'); const summary = document.createElement('div');
summary.className = 'saved-summary saved-storage-summary'; summary.className = 'saved-summary saved-storage-summary';
summary.innerHTML = `<span class="saved-storage-text">${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`; const audioCount = items.filter((it) => /\.m4a$/i.test(it.name || '')).length;
summary.innerHTML = `<span class="saved-storage-text">${fmtBytes(total)} · ${items.length} saved${audioCount ? ` · ${audioCount} as audio` : ''}</span>`;
// Every saved YouTube song not yet in compact audio can be shrunk in place.
const shrinkable = items.filter((it) => canShrinkSaved(it));
if (shrinkable.length) {
const shrinkAll = document.createElement('button');
shrinkAll.type = 'button';
shrinkAll.className = 'saved-clear saved-shrink-all';
shrinkAll.textContent = `Shrink ${shrinkable.length}`;
shrinkAll.title = 'Replace these saves with compact audio (HE-AAC): about a third of the space, no video';
shrinkAll.setAttribute('aria-label', `Shrink ${shrinkable.length} saved songs to compact audio`);
shrinkAll.onclick = () => {
const before = shrinkable.reduce((n, it) => n + (it.size || 0), 0);
showModal(`Shrink ${shrinkable.length} saved song${shrinkable.length === 1 ? '' : 's'}?`, document.createTextNode(
`They are swapped for compact audio (HE-AAC, ~48 kbps): sound only, no video, about a third of an audio save and far less than a video (now ${fmtBytes(before)}). The server converts each song once; a song keeps playing from its current copy until the new one is in place.`), [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Shrink', onClick: () => { closeModal(); shrinkSavedAll(shrinkable.map((it) => it.id)); } },
]);
};
summary.appendChild(shrinkAll);
}
summary.appendChild(clearAll); summary.appendChild(clearAll);
$('listFilterBar').classList.remove('hidden'); $('listFilterBar').classList.remove('hidden');
c.appendChild(summary); c.appendChild(summary);
@@ -679,13 +699,20 @@ async function renderSaved() {
onerror="this.classList.add('no-art')" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}${isUploadId(v.id) ? `<span class="upload-badge" title="On this server">${v.kind === 'audio' ? '♪' : '📁'}</span>` : ''}</div> onerror="this.classList.add('no-art')" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}${isUploadId(v.id) ? `<span class="upload-badge" title="On this server">${v.kind === 'audio' ? '♪' : '📁'}</span>` : ''}</div>
<div class="card-info"> <div class="card-info">
<div class="card-title"></div> <div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div> <div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${savedFormatLabel(it)} · ${fmtBytes(it.size)}</div>
</div>`; </div>`;
row.querySelector('.card-title').textContent = v.title || it.id; row.querySelector('.card-title').textContent = v.title || it.id;
row.addEventListener('click', (e) => { row.addEventListener('click', (e) => {
if (e.target.closest('.saved-file-actions')) return; if (e.target.closest('.saved-file-actions')) return;
playFromList([v], 0, 'saved'); playFromList([v], 0, 'saved');
}); });
const shrink = canShrinkSaved(it) ? async () => {
toast(`Shrinking “${v.title || v.id}”…`);
const r = await shrinkSaved(v);
toast(r.ok ? (r.compact ? `Shrunk ${fmtBytes(r.before)} → ${fmtBytes(r.after)}` : 'This song has no smaller version')
: '⚠ ' + (r.error || 'Could not shrink it'));
if (view.type === 'saved') renderSaved();
} : null;
SavedPage.actions(row, v, () => exportToDevice(v), () => { SavedPage.actions(row, v, () => exportToDevice(v), () => {
showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom
? 'This edited copy exists only on this device and will be removed permanently.' ? 'This edited copy exists only on this device and will be removed permanently.'
@@ -708,7 +735,7 @@ async function renderSaved() {
toast('Removed saved file'); renderSaved(); toast('Removed saved file'); renderSaved();
} }, } },
]); ]);
}); }, shrink);
c.appendChild(row); c.appendChild(row);
}); });
if (!c.querySelector('.saved-card')) { if (!c.querySelector('.saved-card')) {
@@ -959,6 +986,13 @@ async function renderSettings() {
</span> </span>
<input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} /> <input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} />
</label> </label>
<label class="set-row">
<span>
Save songs as compact audio
<small>New saves keep only the sound, as HE-AAC (about a third of the space, no video). The server converts each song once. Shrink songs you already saved from Saved videos.</small>
</span>
<input id="setCompactAudio" type="checkbox" ${data.settings.compactAudio ? 'checked' : ''} />
</label>
<label class="set-row"> <label class="set-row">
<span> <span>
Videos saved at once Videos saved at once
@@ -1355,6 +1389,10 @@ async function renderSettings() {
data.settings.autoSavePlayed = e.target.checked; data.settings.autoSavePlayed = e.target.checked;
persist(); persist();
}); });
$('setCompactAudio').addEventListener('change', (e) => {
data.settings.compactAudio = e.target.checked;
persist();
});
$('setAutoPreload').addEventListener('change', (e) => { $('setAutoPreload').addEventListener('change', (e) => {
data.settings.autoPreload = e.target.checked; data.settings.autoPreload = e.target.checked;
persist(); persist();

View File

@@ -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 */ } 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 // 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). // 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 */ } 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 // 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', 'status', 'gen', 'size', 'height', 'vcodec', 'acodec', 'duration', 'optimized',
'meta', 'priority', 'auto', 'attempts', 'error', 'retry_at', 'created_at', 'meta', 'priority', 'auto', 'attempts', 'error', 'retry_at', 'created_at',
'updated_at', 'last_access', 'hits', 'sha256', 'updated_at', 'last_access', 'hits', 'sha256',
'pinned', 'still', 'audio_sha256', 'pinned', 'still', 'audio_sha256', 'he_size', 'he_sha256',
]); ]);
function rowToObj(res, row) { function rowToObj(res, row) {

View File

@@ -168,6 +168,8 @@ function metaFromInfo(info, duration) {
export function createMediaCache({ export function createMediaCache({
dir, db, download, getInfo, getSourceUrl = id => `https://www.youtube.com/watch?v=${id}`, dir, db, download, getInfo, getSourceUrl = id => `https://www.youtube.com/watch?v=${id}`,
ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', nice = 'nice', 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, maxBytes = 10 * 1024 ** 3,
minFreeBytes = 5 * 1024 ** 3, minFreeBytes = 5 * 1024 ** 3,
autoMaxSeconds = 3 * 3600, autoMaxSeconds = 3 * 3600,
@@ -203,6 +205,9 @@ export function createMediaCache({
const stillQueue = []; // copies whose still-picture check hasn't run yet const stillQueue = []; // copies whose still-picture check hasn't run yet
const stillChecks = new Map(); // videoId -> running check (dedupes the two lanes) const stillChecks = new Map(); // videoId -> running check (dedupes the two lanes)
let stillActive = false; 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(); const lastTouch = new Map();
function sweepTmp(prefix) { function sweepTmp(prefix) {
@@ -219,7 +224,7 @@ export function createMediaCache({
let names = []; let names = [];
try { names = readdirSync(dir); } catch { return; } try { names = readdirSync(dir); } catch { return; }
for (const n of names) { 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; if (!m || m[1] !== id || (keepGen !== null && Number(m[2]) === keepGen)) continue;
const p = join(dir, n); const p = join(dir, n);
const rm = () => { try { unlinkSync(p); } catch { /* gone */ } }; const rm = () => { try { unlinkSync(p); } catch { /* gone */ } };
@@ -363,7 +368,7 @@ export function createMediaCache({
status: 'ready', gen, status: 'ready', gen,
size: probe.size + sizeOf(fileFor(id, gen, 'm4a')), size: probe.size + sizeOf(fileFor(id, gen, 'm4a')),
height: probe.height, vcodec: probe.vcodec, acodec: probe.acodec, duration: probe.duration, 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, attempts: 0, error: null, retry_at: 0, updated_at: t, last_access: t, sha256,
}; };
await db.upsertMedia(id, fields); await db.upsertMedia(id, fields);
@@ -371,6 +376,7 @@ export function createMediaCache({
job.status = 'ready'; job.status = 'ready';
log.info?.(`[media] cached ${id} ${probe.height}p ${fmtMB(fields.size)}`); log.info?.(`[media] cached ${id} ${probe.height}p ${fmtMB(fields.size)}`);
queueStill(id); queueStill(id);
queueHe(id);
enqueueOptimize(id); enqueueOptimize(id);
notifyReady(id, gen, sha256, probe, fields.meta); notifyReady(id, gen, sha256, probe, fields.meta);
return { ...(prev || {}), video_id: id, ...fields }; return { ...(prev || {}), video_id: id, ...fields };
@@ -498,6 +504,8 @@ export function createMediaCache({
const oldAudio = fileFor(id, row.gen, 'm4a'); const oldAudio = fileFor(id, row.gen, 'm4a');
const newAudio = fileFor(id, gen, 'm4a'); const newAudio = fileFor(id, gen, 'm4a');
try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); } 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'); const oldArt = fileFor(id, row.gen, 'jpg');
if (existsSync(oldArt)) { try { linkSync(oldArt, fileFor(id, gen, 'jpg')); } catch { copyFileSync(oldArt, fileFor(id, gen, 'jpg')); } } if (existsSync(oldArt)) { try { linkSync(oldArt, fileFor(id, gen, 'jpg')); } catch { copyFileSync(oldArt, fileFor(id, gen, 'jpg')); } }
await db.upsertMedia(id, { await db.upsertMedia(id, {
@@ -568,6 +576,94 @@ export function createMediaCache({
return p; 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 // Keyframes only: ~1 s for a 5-minute video. Every keyframe-to-keyframe
// scene score must stay under STILL_MAX_SCENE. // scene score must stay under STILL_MAX_SCENE.
async function isStill(src) { async function isStill(src) {
@@ -804,6 +900,8 @@ export function createMediaCache({
active: fetchActive ? fetchActive.id : null, active: fetchActive ? fetchActive.id : null,
optimizing: optActive, optimizing: optActive,
optimizeQueued: optQueue.length, 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])); const byId = new Map(rows.map((r) => [r.video_id, r]));
for (const name of readdirSync(dir)) { for (const name of readdirSync(dir)) {
if (name === '.tmp') continue; 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]); const r = m && byId.get(m[1]);
if (!m || !r || r.status !== 'ready' || r.gen !== Number(m[2])) { if (!m || !r || r.status !== 'ready' || r.gen !== Number(m[2])) {
try { unlinkSync(join(dir, name)); } catch { /* gone */ } try { unlinkSync(join(dir, name)); } catch { /* gone */ }
@@ -832,6 +930,7 @@ export function createMediaCache({
if (r.status === 'ready') { if (r.status === 'ready') {
if (!readyFilesExist(r)) { await db.deleteMedia(r.video_id); removeFiles(r.video_id); continue; } 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.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); if ((r.optimized || 0) < OPT_REV) enqueueOptimize(r.video_id);
} else if (r.status === 'queued' || r.status === 'downloading' || r.status === 'validating') { } else if (r.status === 'queued' || r.status === 'downloading' || r.status === 'validating') {
startJob(r.video_id, r.priority === HIGH ? HIGH : LOW, !!r.auto, r); startJob(r.video_id, r.priority === HIGH ? HIGH : LOW, !!r.auto, r);
@@ -869,6 +968,6 @@ export function createMediaCache({
return n; 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') }; evictNow: id => evict(id, 'admin') };
} }

View File

@@ -435,6 +435,39 @@ describe('media cache jobs', () => {
expect(await again.filePath('stilAAAAAA1', after.gen, 'jpg')).toBeTruthy(); expect(await again.filePath('stilAAAAAA1', after.gen, 'jpg')).toBeTruthy();
}, 90000); }, 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 () => { test('a moving copy already done by the HEVC lane is not re-encoded for rev 3', async () => {
await clearDb(); await clearDb();
const seed = makeCache({ fixture: 'fat64.mp4', duration: 64, const seed = makeCache({ fixture: 'fat64.mp4', duration: 64,

View File

@@ -1348,6 +1348,9 @@ const media = createMediaCache({
}, },
ffmpeg: FFMPEG, ffmpeg: FFMPEG,
ffprobe: process.env.FFPROBE_PATH || 'ffprobe', 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), maxBytes: envNum('MEDIA_CACHE_MAX_BYTES', 10 * 1024 ** 3),
minFreeBytes: envNum('MEDIA_MIN_FREE_BYTES', 5 * 1024 ** 3), minFreeBytes: envNum('MEDIA_MIN_FREE_BYTES', 5 * 1024 ** 3),
autoMaxSeconds: envNum('MEDIA_AUTO_MAX_SECONDS', 3 * 3600), 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); }, media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
}); });
const DOWNLOAD_EXPOSE = 'X-Content-SHA256, Content-Range, Content-Length, ETag, Accept-Ranges'; 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) { function cachedDownloadResponse(c, videoId, fp, row) {
if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {}); if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {});
media.touch(videoId); media.touch(videoId);
@@ -1797,6 +1814,20 @@ app.get('/api/download/:videoId/prepare', async (c) => {
if (ready) { if (ready) {
// ?still=1: the device would rather keep the audio + picture of an // ?still=1: the device would rather keep the audio + picture of an
// album-art song than a video of one unchanging frame. // 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; const stillAudio = c.req.query('still') === '1' ? stillAudioPath(videoId, ready) : null;
if (stillAudio) { if (stillAudio) {
media.touch(videoId); media.touch(videoId);
@@ -1879,6 +1910,10 @@ app.get('/api/download/:videoId', async (c) => {
let cacheErr = null; let cacheErr = null;
try { try {
const row = await media.ensureCached(videoId, { priority: HIGH }); 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 // 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). // 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') { if (servableTo(row, c.req.query('hevc') === '1') || stillAudioPath(videoId, row) && c.req.query('still') === '1') {