diff --git a/CLAUDE.md b/CLAUDE.md index fef2392..c5ebdd2 100644 --- a/CLAUDE.md +++ b/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 `..jpg` + `audio_sha256`. Saves + send `?still=1` → the device stores `.m4a` + OPFS `art/.jpg` and plays it as + audio with the picture; online, still songs also play audio + `/api/media/:id/art`. +- **Compact audio (HE-AAC)**: `..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 diff --git a/Dockerfile b/Dockerfile index 4a64db6..e8f5fe8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/* diff --git a/frontend/app.js b/frontend/app.js index 8b02e88..152c2e1 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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}” ✓`); diff --git a/frontend/autosave.test.js b/frontend/autosave.test.js index 198bbde..d4bfedf 100644 --- a/frontend/autosave.test.js +++ b/frontend/autosave.test.js @@ -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')); diff --git a/frontend/opfs.js b/frontend/opfs.js index 248295b..6fac5b4 100644 --- a/frontend/opfs.js +++ b/frontend/opfs.js @@ -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. .mp4 with .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 { diff --git a/frontend/saved-page.css b/frontend/saved-page.css index 139ece1..25b314d 100644 --- a/frontend/saved-page.css +++ b/frontend/saved-page.css @@ -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; } } diff --git a/frontend/saved-page.js b/frontend/saved-page.js index 82f83f5..5610fd0 100644 --- a/frontend/saved-page.js +++ b/frontend/saved-page.js @@ -4,7 +4,8 @@ const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase()); const paths = { export: '', - delete: '' + delete: '', + shrink: '' }; function button(doc, kind, label, action) { const b = doc.createElement('button'); b.type = 'button'; @@ -13,10 +14,14 @@ b.innerHTML = ``; 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); } diff --git a/frontend/views-core.js b/frontend/views-core.js index 2eff79d..b080d15 100644 --- a/frontend/views-core.js +++ b/frontend/views-core.js @@ -658,7 +658,27 @@ async function renderSaved() { c.innerHTML = ''; const summary = document.createElement('div'); summary.className = 'saved-summary saved-storage-summary'; - summary.innerHTML = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline`; + const audioCount = items.filter((it) => /\.m4a$/i.test(it.name || '')).length; + summary.innerHTML = `${fmtBytes(total)} · ${items.length} saved${audioCount ? ` · ${audioCount} as audio` : ''}`; + // 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 ? '✂' : ''}${isUploadId(v.id) ? `${v.kind === 'audio' ? '♪' : '📁'}` : ''}
-
${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
+
${isCustom ? '✂ edited · ' : ''}${savedFormatLabel(it)} · ${fmtBytes(it.size)}
`; 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() { +