feat: add opt-in save-before-playing setting backed by server-compiled single-file downloads
This commit is contained in:
@@ -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/*
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<span>Audio-only mode</span>
|
||||
<input id="setAudioOnly" type="checkbox" ${data.settings.audioOnly ? 'checked' : ''} />
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>
|
||||
Save before playing
|
||||
<small>Downloads the whole video to this device first (the server compiles video + audio into one file), then plays the local copy. One stream instead of two, but playback starts after the download finishes.</small>
|
||||
</span>
|
||||
<input id="setSaveBeforePlay" type="checkbox" ${data.settings.saveBeforePlay ? 'checked' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user