fix: stop playback stutter and save failures via async yt-dlp, per-download OPFS workers, lazy playback blobs, GPU composite trims, and landscape pane scrolling

This commit is contained in:
Jonathan Sykes
2026-07-02 23:40:49 +08:00
parent 2af2eebacc
commit ed9f93b0b7
6 changed files with 237 additions and 25 deletions

View File

@@ -22,7 +22,7 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { logger } from 'hono/logger';
import { spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
@@ -82,19 +82,27 @@ const CHANNEL_LIMIT = 60;
// yt-dlp helpers
// ============================================================================
// Run yt-dlp synchronously and return stdout as a string.
// Throws on non-zero exit.
// Run yt-dlp asynchronously and resolve stdout as a string.
// MUST stay async (spawn, not spawnSync): a sync child process blocks Bun's
// event loop for the full yt-dlp runtime (~2-3s per call), which stalls every
// concurrent request — including in-flight /api/download proxy streams, which
// Bun then kills at its idle timeout ("fetch failed" mid-download on clients).
// Rejects on non-zero exit.
function runYtdlp(args) {
const result = spawnSync(YTDLP, args, {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024, // 32 MB — large channel dumps can be big
return new Promise((resolve, reject) => {
const child = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
let err = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err += d; });
child.on('error', (e) => reject(new Error('yt-dlp not found: ' + e.message)));
child.on('close', (code) => {
if (code !== 0) reject(new Error(err.trim() || 'yt-dlp exited with code ' + code));
else resolve(out);
});
});
if (result.error) throw new Error('yt-dlp not found: ' + result.error.message);
if (result.status !== 0) {
const err = (result.stderr || '').trim();
throw new Error(err || 'yt-dlp exited with code ' + result.status);
}
return result.stdout || '';
}
// Helpers to pick the right field from a yt-dlp JSON record
@@ -180,7 +188,7 @@ app.get('/api/search', async (c) => {
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
try {
const out = runYtdlp([
const out = await runYtdlp([
`ytsearch${SEARCH_LIMIT}:${q}`,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
@@ -198,7 +206,7 @@ app.get('/api/channel', async (c) => {
try {
const url = channelToUrl(chan);
const out = runYtdlp([
const out = await runYtdlp([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
@@ -231,7 +239,7 @@ app.get('/api/streams', async (c) => {
try {
const url = `https://www.youtube.com/watch?v=${videoId}`;
const out = runYtdlp(['-J', '--no-warnings', url]);
const out = await runYtdlp(['-J', '--no-warnings', url]);
const info = JSON.parse(out);
const formats = Array.isArray(info.formats) ? info.formats : [];
@@ -300,7 +308,7 @@ app.get('/api/download/:videoId', async (c) => {
// --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"
const out = runYtdlp([
const out = await runYtdlp([
url,
'--no-warnings',
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
@@ -430,6 +438,10 @@ async function main() {
Bun.serve({
port: PORT,
fetch: app.fetch,
// Default is 10s, which killed /api/download proxy streams whenever the
// connection went idle mid-transfer. 240s covers slow saves; Bun caps
// this field at 255.
idleTimeout: 240,
});
console.log(`[ytplayer] Listening → http://localhost:${PORT}`);