From 76f8ec525dff1710d2c06b179836fb2186c15d48 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 3 Jul 2026 00:07:50 +0800 Subject: [PATCH] feat: add opt-in save-before-playing setting backed by server-compiled single-file downloads --- Dockerfile | 3 +++ frontend/app.js | 59 +++++++++++++++++++++++++++++++++++++++++++----- server/server.js | 51 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index f94423a..7f61067 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,10 +14,13 @@ FROM oven/bun:1-debian # ---- System dependencies ---- # python3 is required by yt-dlp for some extraction paths # 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 && \ apt-get install -y --no-install-recommends \ curl \ python3 \ + ffmpeg \ ca-certificates && \ rm -rf /var/lib/apt/lists/* diff --git a/frontend/app.js b/frontend/app.js index be83f64..3cf6c2a 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -71,12 +71,19 @@ function saveDataToStorage(jsonStr) { // OPFS bridge wrappers — return the same shape as the Tauri cache_* commands // so the rest of app.js works without changes. -async function opfsDownload(videoId) { +async function opfsDownload(videoId, { mux = false } = {}) { if (!window.OPFS || !window.OPFS.isSupported()) { return { ok: false, error: 'OPFS not supported in this browser' }; } const fp = window.getFingerprint ? window.getFingerprint() : ''; - const url = `/api/download/${encodeURIComponent(videoId)}${fp ? '?fp=' + encodeURIComponent(fp) : ''}`; + const params = new URLSearchParams(); + if (fp) params.set('fp', fp); + // mux=1 asks the server to compile bestvideo+bestaudio into a single mp4 + // (used by "Save before playing"); default saves keep the progressive + // stream exactly as before. + if (mux) params.set('mux', '1'); + const qs = params.toString(); + const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`; // Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes, // so a big save never touches the main thread (no UI jank, no audio @@ -158,8 +165,8 @@ const API = { ? Promise.resolve(saveDataToStorage(typeof d === 'string' ? d : JSON.stringify(d))) : call('store.save', 'store_save', { data: JSON.stringify(d) }), // Offline cache — routes to OPFS (WEB) or native cache (Tauri/Zig) - cacheDownload: (videoId) => WEB - ? opfsDownload(sanitizeId(videoId)) + cacheDownload: (videoId, opts) => WEB + ? opfsDownload(sanitizeId(videoId), opts) : call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }), cacheStatus: (videoId) => WEB ? opfsStatus(sanitizeId(videoId)) @@ -185,6 +192,7 @@ function toAssetUrl(path) { // ---------- State ---------- const DEFAULT_SETTINGS = { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, + saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming 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 @@ -345,7 +353,7 @@ async function refreshCachedIds() { } // Download a video into the permanent offline cache. Safe to call repeatedly. -async function preload(video, { quiet = false } = {}) { +async function preload(video, { quiet = false, mux = false } = {}) { const id = video.id; if (!id || cachedIds.has(id) || downloading.has(id)) return; downloading.add(id); @@ -359,7 +367,7 @@ async function preload(video, { quiet = false } = {}) { if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); if (!quiet) toast(`Saving “${video.title}” for offline…`); try { - const res = await API.cacheDownload(id); + const res = await API.cacheDownload(id, { mux }); if (res && res.ok && res.cached) { cachedIds.add(id); if (!quiet) toast(`Saved “${video.title}” ✓`); @@ -439,6 +447,10 @@ const Player = { // When false (auto-advance / prev), skip resuming the saved timestamp and // start from the beginning (or the A marker, if an A-B loop is set). this._resumeOnLoad = resume; + // Monotonic load token: any await below may resolve after the user has + // already started a different video — stale loads must stand down + // instead of hijacking playback (longest window: save-before-playing). + const seq = (this._loadSeq = (this._loadSeq || 0) + 1); showSpinner(true); els.placeholder.classList.add('hidden'); // Revoke any previous OPFS blob URL to free memory @@ -469,7 +481,31 @@ const Player = { } } + // Opt-in "Save before playing" (Settings → Playback, off by default): + // download the server-compiled single file into the offline cache + // first, then play the local copy — one data stream instead of the + // dual video+audio streams, and the video is kept for offline reuse. + // Any failure falls through to normal streaming playback. + if (!preferStream && data.settings.saveBeforePlay && !data.settings.audioOnly && + WEB && window.OPFS && window.OPFS.isSupported() && !cachedIds.has(videoObj.id)) { + await preload(videoObj, { mux: true }); + if (seq !== this._loadSeq) return; + if (cachedIds.has(videoObj.id)) { + const localUrl = await window.OPFS.getFileUrl(videoObj.id); + if (seq !== this._loadSeq) return; + if (localUrl) { + _currentBlobUrl = localUrl; + current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl }; + this.afterLoad(); + this.fallbackQueue = []; this.fbIndex = 0; + this.attach(null); + return; + } + } + } + const res = await API.getStreams(videoObj.id); + if (seq !== this._loadSeq) return; // a newer load took over meanwhile if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams'); current = res.data; // Merge richer metadata we may already have from the list card. @@ -1694,6 +1730,13 @@ async function renderSettings() { Audio-only mode +
@@ -1821,6 +1864,10 @@ async function renderSettings() { els.audioOnlyToggle.checked = e.target.checked; persist(); }); + $('setSaveBeforePlay').addEventListener('change', (e) => { + data.settings.saveBeforePlay = e.target.checked; + persist(); + }); $('setAutoPreload').addEventListener('change', (e) => { data.settings.autoPreload = e.target.checked; persist(); diff --git a/server/server.js b/server/server.js index c2a7134..2ab27e5 100644 --- a/server/server.js +++ b/server/server.js @@ -24,7 +24,9 @@ import { serveStatic } from 'hono/bun'; import { logger } from 'hono/logger'; import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; -import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync, openSync, unlinkSync, createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; +import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { initDb, upsertUser, recordVideoAccess, getUserData } from './db.js'; @@ -305,6 +307,53 @@ app.get('/api/download/:videoId', async (c) => { try { const url = `https://www.youtube.com/watch?v=${videoId}`; + + // ?mux=1 — "Save before playing" path: let yt-dlp download bestvideo up + // to 720p PLUS bestaudio and compile them into one mp4 with ffmpeg on + // the server, then stream the finished file. Default saves (no mux) + // keep the progressive single-stream behavior below, unchanged. + if (c.req.query('mux') === '1') { + const tmp = `${tmpdir()}/ytp-mux-${videoId}-${Date.now()}.mp4`; + try { + await runYtdlp([ + url, + '--no-warnings', '--no-playlist', + '-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b', + '--merge-output-format', 'mp4', + '-N', '4', + '-o', tmp, + ]); + const size = statSync(tmp).size; + // Open the fd first, then unlink: on Linux the data stays readable + // until the fd closes, so the temp file cleans itself up even if the + // client disconnects mid-transfer. + const fd = openSync(tmp, 'r'); + unlinkSync(tmp); + const stream = createReadStream('', { fd }); + + const fp = c.req.query('fp'); + if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {}); + + return new Response(Readable.toWeb(stream), { + status: 200, + headers: { + 'Content-Type': 'video/mp4', + 'Content-Length': String(size), + 'Content-Disposition': `attachment; filename="${videoId}.mp4"`, + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', + }, + }); + } catch (err) { + // ffmpeg missing or extraction failed — clean up any partial output + // and fall through to the progressive proxy below. + for (const leftover of [tmp, tmp + '.part']) { + try { unlinkSync(leftover); } catch { /* not created */ } + } + console.warn(`[ytplayer] mux download failed for ${videoId}, falling back to progressive:`, err.message); + } + } + // --get-url with bestvideo+bestaudio/best format is not what we want // here — we need a SINGLE FILE so no ffmpeg muxing is required in the // browser. Use -f "bestvideo[ext=mp4][acodec!=none]/best[ext=mp4]/best"