diff --git a/AGENTS.md b/AGENTS.md index 0a05c8e..26e2379 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ Runtime: - `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option, default `0`. - `FFMPEG_HTTP_RECONNECT`: enable ffmpeg HTTP reconnect options for HTTP inputs, default `1`. - `FFMPEG_HTTP_RECONNECT_DELAY_MAX`: max ffmpeg reconnect delay, default `2`. -- `FFMPEG_HTTP_RECONNECT_MAX_RETRIES`: max ffmpeg reconnect retries, default `4`. +- `FFMPEG_HTTP_RECONNECT_MAX_RETRIES`: max ffmpeg reconnect retries, default `4`, applied only when the installed ffmpeg supports `reconnect_max_retries`. - `FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR`: HTTP status list for reconnect, default `5xx`. - `METADATA_PROBE_ENABLED`: probe session duration with ffprobe, default `1` except in `relay`, where default is `0`. - `METADATA_PROBE_TIMEOUT_MS`: ffprobe duration timeout, default `4000`. diff --git a/README.md b/README.md index ad90a00..c01b3ad 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Recently played URLs and favorites are stored globally by the backend. In Docker docker logs -f frame-stream-player ``` -The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads stream inputs sequentially and avoids extra HTTP range connections. If a specific VOD file requires seeking for metadata, set `FFMPEG_INPUT_SEEKABLE=-1` to restore ffmpeg's automatic behavior. For ffmpeg-owned HTTP inputs, reconnect handling is enabled by default with `FFMPEG_HTTP_RECONNECT=1`. +The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads stream inputs sequentially and avoids extra HTTP range connections. If a specific VOD file requires seeking for metadata, set `FFMPEG_INPUT_SEEKABLE=-1` to restore ffmpeg's automatic behavior. For ffmpeg-owned HTTP inputs, reconnect handling is enabled by default with `FFMPEG_HTTP_RECONNECT=1`. `FFMPEG_HTTP_RECONNECT_MAX_RETRIES` is applied only when the installed `ffmpeg` supports that HTTP protocol option. YouTube URLs are resolved server-side with `yt-dlp` before they enter the existing ffmpeg pipeline. Recents and favorites keep the original YouTube URL, while the short-lived playback session uses the resolved media URL and headers returned by `yt-dlp`. Tune the selected format with `YT_DLP_FORMAT` and the resolver timeout with `YT_DLP_TIMEOUT_MS`. diff --git a/server/index.js b/server/index.js index b106899..28515df 100644 --- a/server/index.js +++ b/server/index.js @@ -28,6 +28,7 @@ const FFMPEG_HTTP_RECONNECT = parseBoolean(process.env.FFMPEG_HTTP_RECONNECT, tr const FFMPEG_HTTP_RECONNECT_DELAY_MAX = clampInteger(process.env.FFMPEG_HTTP_RECONNECT_DELAY_MAX, 2, 0, 30); const FFMPEG_HTTP_RECONNECT_MAX_RETRIES = clampInteger(process.env.FFMPEG_HTTP_RECONNECT_MAX_RETRIES, 4, 0, 20); const FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR = process.env.FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR ?? '5xx'; +const FFMPEG_CAPABILITY_TIMEOUT_MS = 3000; const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE); const METADATA_PROBE_ENABLED = parseBoolean(process.env.METADATA_PROBE_ENABLED, PLAYBACK_CONNECTION_MODE !== 'relay'); const METADATA_PROBE_TIMEOUT_MS = clampInteger(process.env.METADATA_PROBE_TIMEOUT_MS, 4 * 1000, 1000, 30 * 1000); @@ -77,6 +78,7 @@ let recentUrls = []; let recentWrite = Promise.resolve(); let favorites = []; let favoritesWrite = Promise.resolve(); +let ffmpegSupportsReconnectMaxRetries = false; app.disable('x-powered-by'); app.use(express.json({ limit: '256kb' })); @@ -429,6 +431,7 @@ setInterval(() => { await Promise.all([ loadRecentUrls(), loadFavorites(), + detectFfmpegHttpReconnectSupport(), ]); server.listen(PORT, () => { @@ -889,6 +892,80 @@ function runFfprobe(args) { }); } +async function detectFfmpegHttpReconnectSupport() { + if (!FFMPEG_HTTP_RECONNECT || FFMPEG_HTTP_RECONNECT_MAX_RETRIES <= 0) { + return; + } + + try { + const output = await runFfmpegCapabilityCheck(['-hide_banner', '-h', 'protocol=http']); + ffmpegSupportsReconnectMaxRetries = /^\s*-reconnect_max_retries\b/m.test(output); + + if (!ffmpegSupportsReconnectMaxRetries) { + logWarn('ffmpeg http option reconnect_max_retries is unsupported; omitting FFMPEG_HTTP_RECONNECT_MAX_RETRIES'); + } + } catch (error) { + ffmpegSupportsReconnectMaxRetries = false; + logWarn(`ffmpeg http option detection failed; omitting FFMPEG_HTTP_RECONNECT_MAX_RETRIES error=${oneLine(error.message)}`); + } +} + +function runFfmpegCapabilityCheck(args) { + return new Promise((resolve, reject) => { + const ffmpeg = spawn(FFMPEG_PATH, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + let timedOut = false; + let settled = false; + + const finish = (callback) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timer); + callback(); + }; + + const timer = setTimeout(() => { + timedOut = true; + stopProcess(ffmpeg); + }, FFMPEG_CAPABILITY_TIMEOUT_MS); + timer.unref(); + + ffmpeg.stdout.on('data', (chunk) => { + output = appendTail(output, chunk); + }); + + ffmpeg.stderr.on('data', (chunk) => { + output = appendTail(output, chunk); + }); + + ffmpeg.on('error', (error) => { + finish(() => reject(error)); + }); + + ffmpeg.on('close', (code, signal) => { + finish(() => { + if (timedOut) { + reject(new Error('ffmpeg capability check timed out.')); + return; + } + + if (code === 0) { + resolve(output); + return; + } + + const detail = redactSecrets(output).trim(); + reject(new Error(detail ? `ffmpeg capability check exited with code ${code}: ${detail}` : `ffmpeg capability check exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`)); + }); + }); + }); +} + async function loadRecentUrls() { try { const raw = await fs.readFile(RECENT_URLS_PATH, 'utf8'); @@ -2507,10 +2584,12 @@ function buildInputArgs(inputUrl, { seekable = true, startTime = 0 } = {}) { '1', '-reconnect_delay_max', String(FFMPEG_HTTP_RECONNECT_DELAY_MAX), - '-reconnect_max_retries', - String(FFMPEG_HTTP_RECONNECT_MAX_RETRIES), ); + if (FFMPEG_HTTP_RECONNECT_MAX_RETRIES > 0 && ffmpegSupportsReconnectMaxRetries) { + args.push('-reconnect_max_retries', String(FFMPEG_HTTP_RECONNECT_MAX_RETRIES)); + } + if (FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR) { args.push('-reconnect_on_http_error', FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR); }