Compact HE-AAC audio saves: server lane with fdkaac, shrink saved songs from Saved videos
This commit is contained in:
15
CLAUDE.md
15
CLAUDE.md
@@ -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
|
||||
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.
|
||||
- Ops: `curl https://worship.hesed.sbs/api/media/stats`; files under
|
||||
`/app/data/media` in the container.
|
||||
- **Still-picture songs**: a fast still-check lane (keyframe scene scores, or 1 fps =
|
||||
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`)
|
||||
- **Shared per video** (every user sees the same copy): `video_notes` holds the live
|
||||
|
||||
@@ -16,11 +16,15 @@ FROM oven/bun:1-debian
|
||||
# ca-certificates for HTTPS fetches from yt-dlp
|
||||
# ffmpeg lets yt-dlp merge bestvideo+bestaudio into a single mp4 for the
|
||||
# "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 \
|
||||
curl \
|
||||
python3 \
|
||||
ffmpeg \
|
||||
fdkaac \
|
||||
ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -412,6 +412,19 @@ const SaveQueue = (() => {
|
||||
// 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 —
|
||||
// { 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) {
|
||||
if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null;
|
||||
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' },
|
||||
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.paused) return { ok: false, paused: true, error: w.error || 'connection lost', received: w.received || 0 };
|
||||
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' };
|
||||
}
|
||||
|
||||
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()) {
|
||||
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');
|
||||
// Album-art songs come back as audio + picture instead of a video of one frame.
|
||||
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 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' };
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
// 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;
|
||||
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
|
||||
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;
|
||||
workerError = w.error || null;
|
||||
}
|
||||
@@ -570,6 +591,7 @@ async function opfsList() {
|
||||
async function opfsDelete(videoId) {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
|
||||
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(); }
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -577,6 +599,7 @@ async function opfsDelete(videoId) {
|
||||
async function opfsClear() {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
|
||||
try { await window.OPFS.clearAll(); } catch { /* ignore */ }
|
||||
CompactSaves.clear();
|
||||
if (window.DeviceDB) { await window.DeviceDB.clear(); if (window.P2PClient) window.P2PClient.changed(); }
|
||||
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
|
||||
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
|
||||
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
|
||||
loopOne: false, // repeat the single current video
|
||||
shuffle: false, // shuffle upcoming tracks when playing a list
|
||||
@@ -1969,6 +1993,58 @@ async function saveStillArt(id) {
|
||||
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
|
||||
// its saved picture. {} for anything else.
|
||||
async function localStill(id) {
|
||||
@@ -2027,6 +2103,7 @@ async function preload(video, { quiet = false, mux = false, retry = false, direc
|
||||
cacheMutations++;
|
||||
// A still-picture song saved as audio isn't the server's video file, so
|
||||
// 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); });
|
||||
warmThumb(thumbUrlFor(id, video));
|
||||
if (!quiet) toast(`Saved “${video.title}” ✓`);
|
||||
|
||||
@@ -16,7 +16,7 @@ function harness({ autoPreload = true, cached = [], result = { ok: true, cached:
|
||||
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 }) },
|
||||
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 playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge'));
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
opts.signal?.addEventListener('abort', abort, { once: true });
|
||||
worker.onmessage = (e) => {
|
||||
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 === 'error') finish({ ok: false, 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);
|
||||
},
|
||||
|
||||
// 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
|
||||
async deleteVideo(videoId) {
|
||||
try {
|
||||
|
||||
@@ -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-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; }
|
||||
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 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; }
|
||||
@@ -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: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; }
|
||||
@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; } }
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
||||
const paths = {
|
||||
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) {
|
||||
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.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');
|
||||
group.className = 'saved-file-actions'; group.setAttribute('role', 'group');
|
||||
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));
|
||||
row.append(group);
|
||||
}
|
||||
|
||||
@@ -658,7 +658,27 @@ async function renderSaved() {
|
||||
c.innerHTML = '';
|
||||
const summary = document.createElement('div');
|
||||
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);
|
||||
$('listFilterBar').classList.remove('hidden');
|
||||
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>
|
||||
<div class="card-info">
|
||||
<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>`;
|
||||
row.querySelector('.card-title').textContent = v.title || it.id;
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.saved-file-actions')) return;
|
||||
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), () => {
|
||||
showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom
|
||||
? '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();
|
||||
} },
|
||||
]);
|
||||
});
|
||||
}, shrink);
|
||||
c.appendChild(row);
|
||||
});
|
||||
if (!c.querySelector('.saved-card')) {
|
||||
@@ -959,6 +986,13 @@ async function renderSettings() {
|
||||
</span>
|
||||
<input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} />
|
||||
</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">
|
||||
<span>
|
||||
Videos saved at once
|
||||
@@ -1355,6 +1389,10 @@ async function renderSettings() {
|
||||
data.settings.autoSavePlayed = e.target.checked;
|
||||
persist();
|
||||
});
|
||||
$('setCompactAudio').addEventListener('change', (e) => {
|
||||
data.settings.compactAudio = e.target.checked;
|
||||
persist();
|
||||
});
|
||||
$('setAutoPreload').addEventListener('change', (e) => {
|
||||
data.settings.autoPreload = e.target.checked;
|
||||
persist();
|
||||
|
||||
@@ -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