Support X and Twitter video URLs with reliable resolved playback

Share yt-dlp resolution across YouTube and X/Twitter, preserve separate audio/video inputs and headers, and use finite duration metadata directly. Prefer direct HTTPS formats for X, support multi-video posts and explicit selections, and enforce single-connection hosts before resolution and on resolved sources.

Include the related recorded-media probing, seeking recovery, and metadata polling fixes, with regression coverage for playback and protected sources.
This commit is contained in:
2026-10-23 00:26:57 -07:00
parent 91e96894b7
commit 87286c748b
8 changed files with 722 additions and 64 deletions
+12 -4
View File
@@ -77,7 +77,13 @@ The regression history matters:
Default code behavior is `split` when no mode is set. The Compose example uses `relay` because it is the mode to try for IPTV streams. Default code behavior is `split` when no mode is set. The Compose example uses `relay` because it is the mode to try for IPTV streams.
Finite-duration sessions are treated as recorded video and seekable when metadata probing is enabled. Metadata probing is enabled by default except in `relay`, where it defaults off to preserve the one-upstream-connection behavior for IPTV-style streams. When the configured mode is `relay` and duration metadata is known, recorded sessions switch to the seek-capable `split` path; live/unknown-duration streams stay in relay mode. Finite-duration sessions are treated as recorded video and seekable once duration metadata is available. Metadata probing defaults off for unknown/live URLs in `relay` to preserve one upstream connection, but local files, YouTube and X/Twitter URLs without resolved duration metadata, recognized media file extensions, and Jellyfin download/stream URLs are probed automatically. Likely recorded sources retry failed probes up to three times in the background, keeping metadata pending until they finish. The timeout grows by a factor of three per attempt, capped at 30 seconds (default 4, 12, then 30 seconds), with 1- and 2-second retry delays. The frontend polls for the full pending period, including after stream reconnects.
When configured for `relay`, recognized recorded files use `split` immediately, before duration is known. HTTP inputs for these files allow automatic range seeking from startup, including when `FFMPEG_INPUT_SEEKABLE=0` is configured for live streams. This avoids reading an entire MP4 through a pipe to find trailing metadata. Other sessions with known duration also use `split`; unknown/live streams stay in relay mode.
YouTube and X/Twitter sessions use the shared yt-dlp resolver and `split`, with separate resolved video and audio URLs when needed, subject to `SINGLE_CONNECTION_HOSTS` protections. Recognize `x.com` and `twitter.com`, including their `www`, `m`, and `mobile` variants. X posts can return playlists even with `--no-playlist`; use `--playlist-items 1` and unwrap the first entry, while preserving explicit `/video/N` selections. X/Twitter defaults to direct HTTPS formats (preferring combined MP4s), since the source proxy does not rewrite relative HLS segment URLs. Direct MP4 formats may omit codec names: only the explicit value `none` means a stream is absent. The audio proxy must use the audio URL and its headers; frames and probes use the video URL and its headers. Enable yt-dlp's Node runtime explicitly with `--js-runtimes node:<process.execPath>`. Use yt-dlp's finite, non-live duration metadata directly to avoid an extra ffprobe connection.
IPTV subscriptions such as Oceanstream permit only one stream per customer. `SINGLE_CONNECTION_HOSTS` lists protected hostnames, including their subdomains; these sessions always use `relay`, never metadata probing or `split`, even for recorded-file URLs or when global probing/split is enabled. Seeking and recovery must not bypass this rule. Unknown live streams with probing disabled must not gain a metadata probe during recovery. The deployed list also includes the configured IPTV aliases `line.oscreen.cc` and `yeahtv.live`.
## ffmpeg Pipelines ## ffmpeg Pipelines
@@ -175,17 +181,18 @@ Runtime:
- `PORT`: HTTP port, default `3000`. - `PORT`: HTTP port, default `3000`.
- `FFMPEG_PATH`: ffmpeg binary path, default `ffmpeg`. - `FFMPEG_PATH`: ffmpeg binary path, default `ffmpeg`.
- `YT_DLP_PATH`: yt-dlp binary path, default `yt-dlp`. - `YT_DLP_PATH`: yt-dlp binary path, default `yt-dlp`.
- `YT_DLP_FORMAT`: yt-dlp format selector for YouTube URLs, default `best[ext=mp4][vcodec!=none][acodec!=none]/best[vcodec!=none][acodec!=none]/best`. - `YT_DLP_FORMAT`: format selector override for YouTube and X/Twitter. YouTube defaults to `bestvideo[protocol=https]+bestaudio[protocol=https]/bestvideo+bestaudio/best`; X/Twitter defaults to `best[protocol=https]/bestvideo[protocol=https]+bestaudio[protocol=https]`. Separate audio/video inputs and combined sources are supported.
- `YT_DLP_TIMEOUT_MS`: yt-dlp resolution timeout, default `45000`. - `YT_DLP_TIMEOUT_MS`: yt-dlp resolution timeout, default `45000`.
- `FFMPEG_LOG_LEVEL`: ffmpeg log level, default `warning`. - `FFMPEG_LOG_LEVEL`: ffmpeg log level, default `warning`.
- `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option, default `0`. - `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option for unknown/live sources, default `0`; recognized recorded sources use automatic range seeking.
- `FFMPEG_HTTP_RECONNECT`: enable ffmpeg HTTP reconnect options for HTTP inputs, default `1`. - `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_DELAY_MAX`: max ffmpeg reconnect delay, default `2`.
- `FFMPEG_HTTP_RECONNECT_MAX_RETRIES`: max ffmpeg reconnect retries, default `4`, applied only when the installed ffmpeg supports `reconnect_max_retries`. - `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`. - `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_ENABLED`: probe session duration with ffprobe, default `1` except in `relay`, where default is `0`.
- `METADATA_PROBE_TIMEOUT_MS`: ffprobe duration timeout, default `4000`. - `METADATA_PROBE_TIMEOUT_MS`: initial ffprobe duration timeout, default `4000`; recorded-source retry timeouts increase up to `30000`.
- `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`. - `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`.
- `SINGLE_CONNECTION_HOSTS`: comma-separated subscription hosts that must use relay with no metadata probes or seeking; also matches their subdomains.
- `RECENT_URLS_PATH`: recent URL JSON path. - `RECENT_URLS_PATH`: recent URL JSON path.
- `RECENT_URL_LIMIT`: recent URL count, default `12`. - `RECENT_URL_LIMIT`: recent URL count, default `12`.
- `FAVORITES_PATH`: favorites JSON path. - `FAVORITES_PATH`: favorites JSON path.
@@ -227,6 +234,7 @@ Basic validation:
```sh ```sh
node --check server/index.js node --check server/index.js
npm test
docker compose -f docker-compose-example.yml config docker compose -f docker-compose-example.yml config
docker build -t frame-stream-player . docker build -t frame-stream-player .
``` ```
+9 -5
View File
@@ -14,11 +14,11 @@ npm install
npm start npm start
``` ```
Open `http://localhost:3000`, paste a direct HTTP(S) stream URL, and click `Play`. Use `Queue` to add a URL to Recents without starting playback. Open `http://localhost:3000`, paste a direct HTTP(S) stream URL, YouTube link, or X/Twitter video post URL, and click `Play`. Use `Queue` to add a URL to Recents without starting playback.
Set `LOCAL_VIDEOS=/path/to/videos` before starting the server to enable `Play Local`. The picker lets you navigate directories under that root, search the library, and play selected files through the server-side ffmpeg pipeline as MP3 audio plus JPEG canvas frames. Set `LOCAL_VIDEOS=/path/to/videos` before starting the server to enable `Play Local`. The picker lets you navigate directories under that root, search the library, and play selected files through the server-side ffmpeg pipeline as MP3 audio plus JPEG canvas frames.
You need an `ffmpeg` binary with decoders for the stream's video and audio codecs. YouTube page links also need `yt-dlp`; Docker installs it automatically from the upstream master branch at image build time. If your stream is H.264 or HEVC, make sure your installed `ffmpeg` actually includes those decoders. You can point the app at different binaries with: You need an `ffmpeg` binary with decoders for the stream's video and audio codecs. YouTube and X/Twitter page links also need `yt-dlp`; Docker installs it automatically from the upstream master branch at image build time. If your stream is H.264 or HEVC, make sure your installed `ffmpeg` actually includes those decoders. You can point the app at different binaries with:
```sh ```sh
FFMPEG_PATH=/path/to/ffmpeg npm start FFMPEG_PATH=/path/to/ffmpeg npm start
@@ -52,9 +52,11 @@ To use local playback in Docker, mount a host video directory into the container
docker logs -f frame-stream-player 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`. `FFMPEG_HTTP_RECONNECT_MAX_RETRIES` is applied only when the installed `ffmpeg` supports that HTTP protocol option. The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads unknown/live stream inputs sequentially and avoids extra HTTP range connections. Recognized recorded files and sessions with a known duration automatically allow HTTP range requests from the start, so ffmpeg can jump to MP4 metadata instead of downloading the entire file first. 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`. YouTube and X/Twitter URLs are resolved server-side with `yt-dlp`, with the app's Node runtime explicitly enabled for JavaScript challenges. Both `x.com` and `twitter.com` links work, including their `www`, `m`, and `mobile` variants. For posts with multiple videos, the first video plays unless the URL selects one with `/video/N`. The YouTube default selector is `bestvideo[protocol=https]+bestaudio[protocol=https]/bestvideo+bestaudio/best`. X/Twitter defaults to `best[protocol=https]/bestvideo[protocol=https]+bestaudio[protocol=https]`, preferring progressive MP4s because the source proxy does not rewrite relative HLS segment URLs. Audio and video can have separate URLs and request headers: each is proxied to its corresponding ffmpeg worker in `split` mode. Combined audio/video URLs also work. Finite, non-live duration metadata from yt-dlp enables seeking without another source probe. Recents and favorites keep the original page URL. Tune the selected format with `YT_DLP_FORMAT` and the resolver timeout with `YT_DLP_TIMEOUT_MS`.
Set `SINGLE_CONNECTION_HOSTS` to a comma-separated list of IPTV subscription hosts, for example `oceanstream.me`. Matching hosts and their subdomains always use `relay`, skip metadata probes, and disable seeking and recovery probes, even if the URL looks like a recorded file or the global mode is `split`. This protects subscriptions that permit only one upstream stream. Include any alternate provider hostnames in the list.
JPEG frames are dropped when the browser WebSocket falls behind instead of letting stale frames queue indefinitely. Tune the server-side backlog cap with `MAX_WS_BUFFER_BYTES`; the default is `2097152`. JPEG frames are dropped when the browser WebSocket falls behind instead of letting stale frames queue indefinitely. Tune the server-side backlog cap with `MAX_WS_BUFFER_BYTES`; the default is `2097152`.
@@ -68,7 +70,9 @@ Available playback modes:
- `relay`: One source connection from the backend, then the compressed input bytes are teed into separate audio and frame `ffmpeg` workers. This is intended for IPTV hosts that stop early or reject multiple active connections. - `relay`: One source connection from the backend, then the compressed input bytes are teed into separate audio and frame `ffmpeg` workers. This is intended for IPTV hosts that stop early or reject multiple active connections.
- `single`: One source connection and one `ffmpeg` worker with both audio and frame outputs. This is the simplest one-connection fallback, but audio and frame delivery can affect each other. - `single`: One source connection and one `ffmpeg` worker with both audio and frame outputs. This is the simplest one-connection fallback, but audio and frame delivery can affect each other.
Finite-duration streams are treated as recorded video and become seekable once metadata is available. Metadata probing is enabled by default except in `relay`, where `METADATA_PROBE_ENABLED=0` avoids extra upstream connections. Set `METADATA_PROBE_ENABLED=1` if you want recorded relay sessions to become seekable and switch to the seek-capable `split` path. Finite-duration streams are treated as recorded video and become seekable once metadata is available. Metadata probing is enabled by default except in `relay`, where it is disabled for unknown/live URLs to avoid extra upstream connections. Local files, YouTube and X/Twitter URLs without resolved duration metadata, recognized media file extensions, and Jellyfin download/stream URLs are probed automatically. Set `METADATA_PROBE_ENABLED=1` to probe other URLs as well.
Recognized recorded files use `split` immediately when the configured mode is `relay`, even while duration detection is pending. Other relay sessions switch to `split` once a finite duration is known. Failed probes for likely recorded sources retry up to three times in the background, with increasing timeouts (by default 4, 12, then 30 seconds) and short delays. `METADATA_PROBE_TIMEOUT_MS` sets the initial timeout; each attempt is capped at 30 seconds. Seeking becomes available when the probe succeeds, without restarting playback.
Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `8388608`. Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `8388608`.
+3
View File
@@ -14,6 +14,9 @@ services:
# relay: one upstream connection, separate audio/frame ffmpeg workers. # relay: one upstream connection, separate audio/frame ffmpeg workers.
# single: one upstream connection and one ffmpeg worker; fallback only. # single: one upstream connection and one ffmpeg worker; fallback only.
PLAYBACK_CONNECTION_MODE: relay PLAYBACK_CONNECTION_MODE: relay
# Comma-separated subscription hosts (including subdomains): force relay,
# skip metadata probes, and disable seeks that would open extra streams.
# SINGLE_CONNECTION_HOSTS: oceanstream.me
FFMPEG_LOG_LEVEL: warning FFMPEG_LOG_LEVEL: warning
FFMPEG_INPUT_SEEKABLE: "0" FFMPEG_INPUT_SEEKABLE: "0"
FFMPEG_HTTP_RECONNECT: "1" FFMPEG_HTTP_RECONNECT: "1"
+1
View File
@@ -6,6 +6,7 @@
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {
"start": "node server/index.js", "start": "node server/index.js",
"test": "node --test",
"dev": "node --watch server/index.js" "dev": "node --watch server/index.js"
}, },
"keywords": [], "keywords": [],
+16 -12
View File
@@ -68,7 +68,6 @@ const FRAME_UNPAINTED_PACKET_RESET_MS = 20000;
const PLAYBACK_RESTART_COOLDOWN_MS = 15000; const PLAYBACK_RESTART_COOLDOWN_MS = 15000;
const PLAYBACK_RESTART_DELAY_MS = 750; const PLAYBACK_RESTART_DELAY_MS = 750;
const MIN_RECOVERY_RESUME_SECONDS = 2; const MIN_RECOVERY_RESUME_SECONDS = 2;
const METADATA_REFRESH_ATTEMPTS = 20;
const METADATA_REFRESH_INTERVAL_MS = 650; const METADATA_REFRESH_INTERVAL_MS = 650;
const state = { const state = {
@@ -487,7 +486,6 @@ function startSession(session) {
connectPlaybackStreams(); connectPlaybackStreams();
startFrameWatchdog(); startFrameWatchdog();
void refreshSessionMetadata(session.id);
} }
function connectPlaybackStreams() { function connectPlaybackStreams() {
@@ -546,6 +544,7 @@ function connectPlaybackStreams() {
elements.audio.load(); elements.audio.load();
void playAudio(); void playAudio();
startRenderLoop(); startRenderLoop();
void refreshSessionMetadata(session.id);
} }
async function playAudio() { async function playAudio() {
@@ -1356,23 +1355,25 @@ async function seekTo(value) {
} }
async function refreshSessionMetadata(sessionId) { async function refreshSessionMetadata(sessionId) {
const generation = state.generation; const streamGeneration = state.streamGeneration;
for (let attempt = 0; attempt < METADATA_REFRESH_ATTEMPTS; attempt += 1) {
if (generation !== state.generation || state.session?.id !== sessionId) {
return;
}
// The server bounds probe retries. Keep polling for their full lifetime,
// including after reconnects, instead of giving up before a slow probe ends.
while (streamGeneration === state.streamGeneration && state.session?.id === sessionId && state.session.metadataStatus === 'pending') {
try { try {
const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}`, { cache: 'no-store' }); const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}`, { cache: 'no-store' });
if (!response.ok) { if (!response.ok) {
if (response.status === 404) {
return; return;
} }
throw new Error('Metadata temporarily unavailable.');
}
const payload = await response.json(); const payload = await response.json();
if (generation !== state.generation || state.session?.id !== sessionId) { if (streamGeneration !== state.streamGeneration || state.session?.id !== sessionId) {
return; return;
} }
@@ -1383,7 +1384,7 @@ async function refreshSessionMetadata(sessionId) {
return; return;
} }
} catch { } catch {
return; // A transient network failure should not leave seeking disabled either.
} }
await delay(METADATA_REFRESH_INTERVAL_MS); await delay(METADATA_REFRESH_INTERVAL_MS);
@@ -1391,11 +1392,14 @@ async function refreshSessionMetadata(sessionId) {
} }
function updateSessionMetadata(payload) { function updateSessionMetadata(payload) {
if ('duration' in payload) { // A delayed WebSocket ready message can still contain the initial null
// duration after polling has already received the completed probe result.
const hasDuration = normalizeDuration(payload.duration) !== null;
if ('duration' in payload && (hasDuration || state.duration === null)) {
state.duration = normalizeDuration(payload.duration); state.duration = normalizeDuration(payload.duration);
} }
if ('seekable' in payload) { if ('seekable' in payload && (hasDuration || state.duration === null)) {
state.seekable = Boolean(payload.seekable); state.seekable = Boolean(payload.seekable);
} }
+148 -42
View File
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import express from 'express'; import express from 'express';
@@ -20,7 +21,10 @@ const PORT = Number(process.env.PORT ?? 3000);
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg'; const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
const FFPROBE_PATH = process.env.FFPROBE_PATH ?? 'ffprobe'; const FFPROBE_PATH = process.env.FFPROBE_PATH ?? 'ffprobe';
const YT_DLP_PATH = process.env.YT_DLP_PATH ?? 'yt-dlp'; const YT_DLP_PATH = process.env.YT_DLP_PATH ?? 'yt-dlp';
const YT_DLP_FORMAT = process.env.YT_DLP_FORMAT ?? 'best[ext=mp4][vcodec!=none][acodec!=none]/best[vcodec!=none][acodec!=none]/best'; const YT_DLP_FORMAT = process.env.YT_DLP_FORMAT ?? 'bestvideo[protocol=https]+bestaudio[protocol=https]/bestvideo+bestaudio/best';
// X's HLS playlists contain relative segment URLs, which the source proxy does
// not rewrite. Prefer its progressive MP4s; keep separate HTTPS inputs usable.
const YT_DLP_TWITTER_FORMAT = process.env.YT_DLP_FORMAT ?? 'best[protocol=https]/bestvideo[protocol=https]+bestaudio[protocol=https]';
const YT_DLP_TIMEOUT_MS = clampInteger(process.env.YT_DLP_TIMEOUT_MS, 45 * 1000, 5 * 1000, 120 * 1000); const YT_DLP_TIMEOUT_MS = clampInteger(process.env.YT_DLP_TIMEOUT_MS, 45 * 1000, 5 * 1000, 120 * 1000);
const FFMPEG_LOG_LEVEL = process.env.FFMPEG_LOG_LEVEL ?? 'warning'; const FFMPEG_LOG_LEVEL = process.env.FFMPEG_LOG_LEVEL ?? 'warning';
const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0'; const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0';
@@ -30,8 +34,11 @@ const FFMPEG_HTTP_RECONNECT_MAX_RETRIES = clampInteger(process.env.FFMPEG_HTTP_R
const FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR = process.env.FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR ?? '5xx'; const FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR = process.env.FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR ?? '5xx';
const FFMPEG_CAPABILITY_TIMEOUT_MS = 3000; const FFMPEG_CAPABILITY_TIMEOUT_MS = 3000;
const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE); const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE);
const SINGLE_CONNECTION_HOSTS = (process.env.SINGLE_CONNECTION_HOSTS ?? '').split(',')
.map((host) => host.trim().toLowerCase().replace(/\.$/, '')).filter(Boolean);
const METADATA_PROBE_ENABLED = parseBoolean(process.env.METADATA_PROBE_ENABLED, PLAYBACK_CONNECTION_MODE !== 'relay'); 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); const METADATA_PROBE_TIMEOUT_MS = clampInteger(process.env.METADATA_PROBE_TIMEOUT_MS, 4 * 1000, 1000, 30 * 1000);
const METADATA_PROBE_MAX_ATTEMPTS = 3;
const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json'); const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json');
const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50); const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json'); const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json');
@@ -202,20 +209,23 @@ app.post('/api/session', async (request, response) => {
const options = parsePlaybackOptions(request.body); const options = parsePlaybackOptions(request.body);
const id = randomUUID(); const id = randomUUID();
const shouldProbeMetadata = METADATA_PROBE_ENABLED || canBestEffortResumeWithoutDuration({ const duration = Number.isFinite(source.duration) && source.duration > 0 ? source.duration : null;
const shouldProbeMetadata = !source.singleConnection && duration === null && (METADATA_PROBE_ENABLED || canBestEffortResumeWithoutDuration({
sourceKind: source.kind, sourceKind: source.kind,
originalUrl: source.originalUrl, originalUrl: source.originalUrl,
url: source.url, url: source.url,
}); }));
const metadataStatus = shouldProbeMetadata ? 'pending' : 'disabled'; const metadataStatus = duration !== null ? 'ready' : shouldProbeMetadata ? 'pending' : 'disabled';
const session = { const session = {
id, id,
url: source.url, url: source.url,
originalUrl: source.originalUrl, originalUrl: source.originalUrl,
sourceKind: source.kind, sourceKind: source.kind,
sourceHeaders: source.headers, sourceHeaders: source.headers,
audioSource: source.audioSource ?? null,
singleConnection: source.singleConnection ?? false,
options, options,
duration: null, duration,
metadataStatus, metadataStatus,
metadataProbePromise: null, metadataProbePromise: null,
seekSeconds: 0, seekSeconds: 0,
@@ -272,7 +282,7 @@ app.post('/api/session/:sessionId/seek', async (request, response) => {
const allowBestEffort = request.body?.allowBestEffort === true; const allowBestEffort = request.body?.allowBestEffort === true;
if (!isSessionSeekable(session) && allowBestEffort && session.metadataStatus !== 'unavailable') { if (!isSessionSeekable(session) && allowBestEffort && canBestEffortResumeWithoutDuration(session) && session.metadataStatus !== 'unavailable') {
await ensureSessionDuration(session); await ensureSessionDuration(session);
} }
@@ -334,7 +344,9 @@ app.all('/_source/:token', async (request, response) => {
response.on('close', cleanup); response.on('close', cleanup);
try { try {
const headers = createSourceRequestHeaders(session); const input = source.kind === 'audio' && session.audioSource
? session.audioSource : { url: session.url, headers: session.sourceHeaders };
const headers = createSourceRequestHeaders(input.headers);
const range = request.get('range'); const range = request.get('range');
if (range) { if (range) {
@@ -343,7 +355,7 @@ app.all('/_source/:token', async (request, response) => {
headers.set('accept-encoding', 'identity'); headers.set('accept-encoding', 'identity');
const upstream = await fetch(session.url, { const upstream = await fetch(input.url, {
method: request.method === 'HEAD' ? 'HEAD' : 'GET', method: request.method === 'HEAD' ? 'HEAD' : 'GET',
headers, headers,
redirect: 'follow', redirect: 'follow',
@@ -704,44 +716,75 @@ function parseResolvedMediaUrl(value) {
} }
async function resolvePlaybackSource(url) { async function resolvePlaybackSource(url) {
if (!isYouTubeUrl(url)) { const singleConnection = requiresSingleConnection(url);
const kind = singleConnection ? null : getYtDlpSourceKind(url);
if (!kind) {
return { return {
kind: 'direct', kind: 'direct',
url, url,
originalUrl: url, originalUrl: url,
headers: {}, headers: {},
singleConnection,
}; };
} }
const resolved = await resolveYouTubeSource(url); const resolved = await resolveYtDlpSource(url, kind);
const resolvedSingleConnection = requiresSingleConnection(resolved.url)
|| requiresSingleConnection(resolved.audioSource.url);
if (resolvedSingleConnection && resolved.url !== resolved.audioSource.url) {
throw new Error('Resolved audio and video require separate connections to a single-connection host.');
}
return { return {
kind: 'youtube', kind,
url: resolved.url, url: resolved.url,
originalUrl: url, originalUrl: url,
headers: resolved.headers, headers: resolved.headers,
audioSource: resolved.audioSource,
duration: resolvedSingleConnection ? null : resolved.duration,
singleConnection: resolvedSingleConnection,
}; };
} }
function isYouTubeUrl(value) { function requiresSingleConnection(value) {
const hostname = new URL(value).hostname.toLowerCase().replace(/\.$/, '');
return SINGLE_CONNECTION_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`));
}
function getYtDlpSourceKind(value) {
let parsed; let parsed;
try { try {
parsed = new URL(value); parsed = new URL(value);
} catch { } catch {
return false; return null;
} }
const hostname = parsed.hostname.toLowerCase(); const hostname = parsed.hostname.toLowerCase();
return hostname === 'youtu.be' if (hostname === 'youtu.be'
|| hostname.endsWith('.youtu.be') || hostname.endsWith('.youtu.be')
|| hostname === 'youtube.com' || hostname === 'youtube.com'
|| hostname.endsWith('.youtube.com') || hostname.endsWith('.youtube.com')
|| hostname === 'youtube-nocookie.com' || hostname === 'youtube-nocookie.com'
|| hostname.endsWith('.youtube-nocookie.com'); || hostname.endsWith('.youtube-nocookie.com')) {
return 'youtube';
} }
async function resolveYouTubeSource(url) { if (/^(?:(?:www|m|mobile)\.)?(?:x|twitter)\.com$/.test(hostname)) {
return 'twitter';
}
return null;
}
function isYtDlpSourceKind(kind) {
return kind === 'youtube' || kind === 'twitter';
}
async function resolveYtDlpSource(url, kind) {
const startedAt = Date.now(); const startedAt = Date.now();
logInfo(`yt-dlp resolving source host=${new URL(url).hostname}`); logInfo(`yt-dlp resolving source host=${new URL(url).hostname}`);
@@ -750,29 +793,52 @@ async function resolveYouTubeSource(url) {
try { try {
payload = await runYtDlpJson([ payload = await runYtDlpJson([
'--no-playlist', '--no-playlist',
// Twitter can return a playlist even with --no-playlist unless the URL
// selects /video/N. Resolve only the first video of an unselected post.
...(kind === 'twitter' ? ['--playlist-items', '1'] : []),
'--no-warnings', '--no-warnings',
'--no-progress', '--no-progress',
'--dump-single-json', '--dump-single-json',
'--js-runtimes',
`node:${process.execPath}`,
'--format', '--format',
YT_DLP_FORMAT, kind === 'twitter' ? YT_DLP_TWITTER_FORMAT : YT_DLP_FORMAT,
url, url,
]); ]);
} catch (error) { } catch (error) {
throw new Error(`yt-dlp failed to resolve YouTube URL: ${error.message}`); const detail = oneLine(error.message).replace(/https?:\/\/[^\s"'<>]+/g, '<url>');
logWarn(`yt-dlp resolve failed host=${new URL(url).hostname} durationMs=${Date.now() - startedAt} error=${detail}`);
throw new Error(`yt-dlp failed to resolve ${kind === 'youtube' ? 'YouTube' : 'X/Twitter'} URL: ${error.message}`);
} }
const mediaUrl = typeof payload?.url === 'string' ? payload.url : ''; if (Array.isArray(payload?.entries)) {
payload = payload.entries[0];
if (!mediaUrl) {
throw new Error('yt-dlp did not return a playable media URL.');
} }
const parsed = parseResolvedMediaUrl(mediaUrl); const formats = Array.isArray(payload?.requested_formats) && payload.requested_formats.length > 0
logInfo(`yt-dlp resolved source host=${new URL(parsed).hostname} durationMs=${Date.now() - startedAt}`); ? payload.requested_formats : [payload];
// Like yt-dlp's format selector, treat an unspecified codec as unknown;
// Twitter's direct MP4 formats may omit both. Only "none" means absent.
const video = formats.find((format) => format?.url && format.vcodec !== 'none');
const audio = formats.find((format) => format?.url && format.acodec !== 'none');
if (!video || !audio) {
throw new Error('yt-dlp did not return playable video and audio streams.');
}
const parsed = parseResolvedMediaUrl(video.url);
const audioUrl = parseResolvedMediaUrl(audio.url);
const duration = Number(payload.duration);
logInfo(`yt-dlp resolved source host=${new URL(parsed).hostname} separateAudio=${parsed !== audioUrl} durationMs=${Date.now() - startedAt}`);
return { return {
url: parsed, url: parsed,
headers: normalizeSourceHeaders(payload?.http_headers), headers: normalizeSourceHeaders({ ...payload.http_headers, ...video.http_headers }),
audioSource: {
url: audioUrl,
headers: normalizeSourceHeaders({ ...payload.http_headers, ...audio.http_headers }),
},
duration: !payload.is_live && payload.live_status !== 'is_live' && Number.isFinite(duration) && duration > 0 ? duration : null,
}; };
} }
@@ -965,7 +1031,7 @@ function formatSessionPayload(session) {
} }
function isSessionSeekable(session) { function isSessionSeekable(session) {
return hasRecordedDuration(session); return !session.singleConnection && hasRecordedDuration(session);
} }
function hasRecordedDuration(session) { function hasRecordedDuration(session) {
@@ -973,8 +1039,11 @@ function hasRecordedDuration(session) {
} }
function canBestEffortResumeWithoutDuration(session) { function canBestEffortResumeWithoutDuration(session) {
return !session.singleConnection && (isYtDlpSourceKind(session.sourceKind) || isLikelyRecordedSession(session));
}
function isLikelyRecordedSession(session) {
return session.sourceKind === 'local' return session.sourceKind === 'local'
|| session.sourceKind === 'youtube'
|| isLikelyRecordedMediaUrl(session.originalUrl) || isLikelyRecordedMediaUrl(session.originalUrl)
|| isLikelyRecordedMediaUrl(session.url); || isLikelyRecordedMediaUrl(session.url);
} }
@@ -995,7 +1064,13 @@ function isJellyfinRecordedMediaPath(pathname) {
} }
function getSessionPlaybackConnectionMode(session) { function getSessionPlaybackConnectionMode(session) {
if (session.sourceKind === 'local') { // Subscription hosts must never gain a second source connection, even for
// files, metadata probing, recovery, or a globally configured split mode.
if (session.singleConnection) {
return 'relay';
}
if (session.sourceKind === 'local' || isYtDlpSourceKind(session.sourceKind)) {
return 'split'; return 'split';
} }
@@ -1003,7 +1078,9 @@ function getSessionPlaybackConnectionMode(session) {
return 'split'; return 'split';
} }
if (PLAYBACK_CONNECTION_MODE === 'relay' && hasRecordedDuration(session)) { // Files need random access even before their duration is known. A pipe can
// otherwise make ffmpeg read an entire MP4 before reaching its metadata.
if (PLAYBACK_CONNECTION_MODE === 'relay' && (hasRecordedDuration(session) || isLikelyRecordedSession(session))) {
return 'split'; return 'split';
} }
@@ -1015,6 +1092,10 @@ function startSessionMetadataProbe(session) {
} }
function ensureSessionDuration(session) { function ensureSessionDuration(session) {
if (session.singleConnection) {
return Promise.resolve(null);
}
if (hasRecordedDuration(session)) { if (hasRecordedDuration(session)) {
return Promise.resolve(session.duration); return Promise.resolve(session.duration);
} }
@@ -1025,7 +1106,7 @@ function ensureSessionDuration(session) {
session.metadataStatus = 'pending'; session.metadataStatus = 'pending';
const probePromise = probeSessionDuration(session).then((duration) => { const probePromise = probeSessionDurationWithRetry(session).then((duration) => {
const current = sessions.get(session.id); const current = sessions.get(session.id);
if (current !== session) { if (current !== session) {
@@ -1034,6 +1115,9 @@ function ensureSessionDuration(session) {
session.duration = duration; session.duration = duration;
session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable'; session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable';
if (Number.isFinite(duration)) {
logInfo(`duration ready id=${shortId(session.id)} seconds=${duration.toFixed(3)}`);
}
return duration; return duration;
}).catch((error) => { }).catch((error) => {
const current = sessions.get(session.id); const current = sessions.get(session.id);
@@ -1055,7 +1139,28 @@ function ensureSessionDuration(session) {
return probePromise; return probePromise;
} }
async function probeSessionDuration(session) { async function probeSessionDurationWithRetry(session) {
const maxAttempts = canBestEffortResumeWithoutDuration(session) ? METADATA_PROBE_MAX_ATTEMPTS : 1;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
if (sessions.get(session.id) !== session) {
return null;
}
try {
return await probeSessionDuration(session, Math.min(METADATA_PROBE_TIMEOUT_MS * (3 ** (attempt - 1)), 30 * 1000));
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
logWarn(`duration probe retry id=${shortId(session.id)} attempt=${attempt}/${maxAttempts} error=${oneLine(error.message)}`);
await delay(attempt * 1000);
}
}
}
async function probeSessionDuration(session, timeoutMs) {
const source = createSourceInput(session.id, 'probe'); const source = createSourceInput(session.id, 'probe');
try { try {
@@ -1067,7 +1172,7 @@ async function probeSessionDuration(session) {
'-of', '-of',
'json', 'json',
source.url, source.url,
]); ], timeoutMs);
const payload = JSON.parse(output); const payload = JSON.parse(output);
const duration = Number(payload?.format?.duration); const duration = Number(payload?.format?.duration);
@@ -1077,7 +1182,7 @@ async function probeSessionDuration(session) {
} }
} }
function runFfprobe(args) { function runFfprobe(args, timeoutMs) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ffprobe = spawn(FFPROBE_PATH, args, { const ffprobe = spawn(FFPROBE_PATH, args, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
@@ -1100,7 +1205,7 @@ function runFfprobe(args) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
timedOut = true; timedOut = true;
stopProcess(ffprobe); stopProcess(ffprobe);
}, METADATA_PROBE_TIMEOUT_MS); }, timeoutMs);
timer.unref(); timer.unref();
ffprobe.stdout.on('data', (chunk) => { ffprobe.stdout.on('data', (chunk) => {
@@ -1791,7 +1896,7 @@ function createRelayPlayback(session) {
sourceController = new AbortController(); sourceController = new AbortController();
try { try {
const headers = createSourceRequestHeaders(session); const headers = createSourceRequestHeaders(session.sourceHeaders);
headers.set('accept-encoding', 'identity'); headers.set('accept-encoding', 'identity');
const upstream = await fetch(session.url, { const upstream = await fetch(session.url, {
@@ -2758,7 +2863,7 @@ function buildRelayFrameArgs(session) {
function buildPlaybackArgs(session, inputUrl) { function buildPlaybackArgs(session, inputUrl) {
return [ return [
...buildInputArgs(inputUrl, { startTime: session.seekSeconds }), ...buildInputArgs(session, inputUrl),
'-map', '-map',
'0:a:0?', '0:a:0?',
'-vn', '-vn',
@@ -2781,7 +2886,7 @@ function buildPlaybackArgs(session, inputUrl) {
function buildAudioArgs(session, inputUrl, inputOptions) { function buildAudioArgs(session, inputUrl, inputOptions) {
return [ return [
...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }), ...buildInputArgs(session, inputUrl, inputOptions),
'-map', '-map',
'0:a:0?', '0:a:0?',
'-vn', '-vn',
@@ -2801,14 +2906,14 @@ function buildAudioArgs(session, inputUrl, inputOptions) {
function buildFrameArgs(session, inputUrl, inputOptions) { function buildFrameArgs(session, inputUrl, inputOptions) {
return [ return [
...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }), ...buildInputArgs(session, inputUrl, inputOptions),
'-map', '-map',
'0:v:0', '0:v:0',
...buildFrameOutputArgs(session, 'pipe:1'), ...buildFrameOutputArgs(session, 'pipe:1'),
]; ];
} }
function buildInputArgs(inputUrl, { seekable = true, startTime = 0 } = {}) { function buildInputArgs(session, inputUrl, { seekable = true, startTime = session.seekSeconds } = {}) {
const isHttpInput = isHttpInputUrl(inputUrl); const isHttpInput = isHttpInputUrl(inputUrl);
const args = [ const args = [
'-hide_banner', '-hide_banner',
@@ -2819,7 +2924,8 @@ function buildInputArgs(inputUrl, { seekable = true, startTime = 0 } = {}) {
]; ];
if (seekable && isHttpInput) { if (seekable && isHttpInput) {
args.push('-seekable', startTime > 0 ? '1' : FFMPEG_INPUT_SEEKABLE); const inputSeekable = hasRecordedDuration(session) || isLikelyRecordedSession(session) ? '-1' : FFMPEG_INPUT_SEEKABLE;
args.push('-seekable', startTime > 0 ? '1' : inputSeekable);
} }
if (startTime > 0) { if (startTime > 0) {
@@ -3120,10 +3226,10 @@ function createLineLogger(callback) {
}; };
} }
function createSourceRequestHeaders(session) { function createSourceRequestHeaders(sourceHeaders) {
const headers = new Headers(); const headers = new Headers();
for (const [name, value] of Object.entries(session.sourceHeaders ?? {})) { for (const [name, value] of Object.entries(sourceHeaders ?? {})) {
headers.set(name, value); headers.set(name, value);
} }
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import { test } from 'node:test';
import vm from 'node:vm';
const source = await fs.readFile(new URL('../public/app.js', import.meta.url), 'utf8');
function loadPlayer(metadataFetch) {
const nodes = new Map();
const createElement = () => ({
hidden: true, disabled: true, value: '', currentTime: 0,
addEventListener() {}, replaceChildren() {}, setAttribute() {},
getContext() { return {}; }, style: { setProperty() {} },
});
const context = vm.createContext({
document: {
querySelector(selector) {
if (!nodes.has(selector)) nodes.set(selector, createElement());
return nodes.get(selector);
},
createElement, addEventListener() {},
},
window: {
addEventListener() {},
// Advance polling without waiting in real time.
setTimeout(callback) { queueMicrotask(callback); },
},
fetch: async url => url.startsWith('/api/session/') ? metadataFetch(url) : ({ ok: true, json: async () => ({}) }),
console,
});
vm.runInContext(source, context);
vm.runInContext("state.session = { id: 'recorded', metadataStatus: 'pending' };", context);
return { nodes, run: code => vm.runInContext(code, context) };
}
test('seeking becomes enabled after a slow probe and transient metadata request errors', async () => {
let polls = 0;
const player = loadPlayer(async () => {
polls += 1;
assert.ok(polls <= 25, 'polling must stop once duration is available');
if (polls === 1) throw new Error('Temporary network failure');
if (polls === 2) return { ok: false, status: 503 };
const ready = polls === 25;
return { ok: true, json: async () => ({
id: 'recorded', metadataStatus: ready ? 'ready' : 'pending',
duration: ready ? 7783 : null, seekable: ready,
}) };
});
await player.run("refreshSessionMetadata('recorded')");
assert.equal(polls, 25, 'must continue beyond the old twenty-poll limit');
assert.equal(player.nodes.get('#seek').disabled, false);
assert.equal(player.nodes.get('#seek').max, '7783');
assert.equal(player.nodes.get('#total-time').textContent, '2:09:43');
});
test('metadata responses from an old stream cannot overwrite a restarted player', async () => {
let resolveMetadata;
const player = loadPlayer(() => new Promise(resolve => { resolveMetadata = resolve; }));
const pending = player.run("refreshSessionMetadata('recorded')");
player.run("state.streamGeneration += 1; state.duration = 60; state.seekable = true; syncPlayhead();");
resolveMetadata({ ok: true, json: async () => ({ metadataStatus: 'unavailable', duration: null, seekable: false }) });
await pending;
assert.equal(player.nodes.get('#seek').disabled, false);
assert.equal(player.nodes.get('#seek').max, '60');
});
test('metadata polling stops when probing finishes without a duration', async () => {
let polls = 0;
const player = loadPlayer(async () => {
polls += 1;
assert.equal(polls, 1);
return { ok: true, json: async () => ({ metadataStatus: 'unavailable', duration: null, seekable: false }) };
});
await player.run("refreshSessionMetadata('recorded')");
assert.equal(player.nodes.get('#seek').disabled, true);
assert.equal(polls, 1);
});
test('a delayed ready message cannot disable seeking after duration is discovered', () => {
const player = loadPlayer(() => { throw new Error('Unexpected metadata request'); });
player.run('updateSessionMetadata({ duration: 7783, seekable: true });');
player.run('handleControlMessage(JSON.stringify({ type: "ready", duration: null, seekable: false }));');
assert.equal(player.nodes.get('#seek').disabled, false);
assert.equal(player.nodes.get('#seek').max, '7783');
});
+447
View File
@@ -0,0 +1,447 @@
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs/promises';
import { createServer } from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import WebSocket from 'ws';
const root = fileURLToPath(new URL('..', import.meta.url));
let directory;
let movie;
let live;
let videoOnly;
let audioOnly;
let combined;
before(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'carplay-playback-test-'));
const inputs = ['-y', '-hide_banner', '-loglevel', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=160x90:rate=12',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', '-t', '8'];
execFileSync('ffmpeg', [...inputs, '-c:v', 'mpeg4', '-c:a', 'aac', `${directory}/movie.mp4`]);
execFileSync('ffmpeg', [...inputs, '-c:v', 'mpeg2video', '-c:a', 'mp2', '-f', 'mpegts', `${directory}/live.ts`]);
const file = await fs.readFile(`${directory}/movie.mp4`);
combined = file;
execFileSync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error', '-i', `${directory}/movie.mp4`,
'-map', '0:v', '-c', 'copy', '-an', `${directory}/video.mp4`,
'-map', '0:a', '-c', 'copy', '-vn', `${directory}/audio.m4a`]);
videoOnly = await fs.readFile(`${directory}/video.mp4`);
audioOnly = await fs.readFile(`${directory}/audio.m4a`);
let offset = 0;
while (file.toString('ascii', offset + 4, offset + 8) !== 'moov') {
const size = file.readUInt32BE(offset);
assert.ok(size >= 8 && offset + size < file.length);
offset += size;
}
// Leave media chunk offsets intact, but put metadata beyond a large padding
// box. HTTP seeking should skip it instead of downloading it through a pipe.
const padding = Buffer.alloc(16 * 1024 * 1024);
padding.writeUInt32BE(padding.length);
padding.write('free', 4);
movie = Buffer.concat([file.subarray(0, offset), padding, file.subarray(offset)]);
live = await fs.readFile(`${directory}/live.ts`);
});
after(async () => { await fs.rm(directory, { recursive: true, force: true }); });
async function until(check, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await check();
if (result) return result;
await delay(50);
}
throw new Error('Timed out waiting for playback condition');
}
async function startApp(t, { mode = 'relay', probe = 'real', resolver, resolverMetadata = {}, resolverFormat, singleConnectionHosts = '', metadataEnabled = '0' } = {}) {
const temp = await fs.mkdtemp(path.join(directory, 'app-'));
const requests = [];
const upstream = createServer((request, response) => {
const pathname = new URL(request.url, 'http://localhost').pathname;
const isLive = pathname === '/live' || pathname === '/iptv.mp4';
const data = isLive ? live : pathname === '/video.mp4' ? videoOnly
: pathname === '/audio.m4a' ? audioOnly : pathname === '/combined.mp4' ? combined : movie;
const branch = pathname === '/video.mp4' ? 'video' : pathname === '/audio.m4a' ? 'audio' : null;
if (branch && (request.headers['x-branch'] !== branch || request.headers['x-common'] !== 'shared')) {
response.writeHead(403).end();
return;
}
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range ?? '');
const start = match ? Number(match[1]) : 0;
const end = match?.[2] ? Math.min(Number(match[2]), data.length - 1) : data.length - 1;
requests.push({ url: pathname, start, range: Boolean(match), branch: request.headers['x-branch'] });
response.writeHead(match ? 206 : 200, {
'content-type': isLive ? 'video/mp2t' : 'video/mp4',
'content-length': end - start + 1,
'accept-ranges': 'bytes',
...(match ? { 'content-range': `bytes ${start}-${end}/${data.length}` } : {}),
});
let position = start;
const timer = setInterval(() => {
if (response.writableNeedDrain) return;
const next = Math.min(position + 64 * 1024, end + 1);
response.write(data.subarray(position, next));
position = next;
if (position > end) response.end();
}, isLive ? 10 : 50);
response.on('close', () => clearInterval(timer));
});
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
const sourceBase = `http://127.0.0.1:${upstream.address().port}`;
let ytDlp = 'yt-dlp';
if (resolver) {
ytDlp = `${temp}/yt-dlp`;
let payload = {
duration: 8, http_headers: { 'X-Common': 'shared' }, ...resolverMetadata,
...(['combined', 'unknown-codecs', 'protected-combined'].includes(resolver) ? {
url: `${sourceBase}/${resolver === 'protected-combined' ? 'iptv.mp4' : 'combined.mp4'}?token=combined-secret`,
...(resolver === 'unknown-codecs' ? {} : { vcodec: 'mpeg4', acodec: 'aac' }),
} : { requested_formats: [
...(resolver === 'missing-video' ? [] : [
{ url: `${sourceBase}/video.mp4?token=video-secret`, vcodec: 'mpeg4', acodec: 'none', http_headers: { 'X-Branch': 'video' } },
]),
...(resolver === 'missing-audio' ? [] : [
{ url: `${sourceBase}/audio.m4a?token=audio-secret`, vcodec: 'none', acodec: 'aac', http_headers: { 'X-Branch': 'audio' } },
]),
] }),
};
if (resolver === 'playlist' || resolver === 'empty-playlist') {
payload = { _type: 'playlist', duration: 999, entries: resolver === 'playlist' ? [payload] : [] };
}
await fs.writeFile(ytDlp, `#!/usr/bin/env node
require('node:fs').writeFileSync(${JSON.stringify(`${temp}/yt-dlp-args.json`)}, JSON.stringify(process.argv.slice(2)));
if (${JSON.stringify(resolver)} === 'fail') {
process.stderr.write('Extraction failed for https://example.test/video?token=resolver-secret');
process.exit(1);
}
process.stdout.write(${JSON.stringify(JSON.stringify(payload))});
`, { mode: 0o755 });
}
const reservation = createServer();
await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve));
const port = reservation.address().port;
await new Promise(resolve => reservation.close(resolve));
let ffprobe = 'ffprobe';
if (probe !== 'real') {
ffprobe = `${temp}/ffprobe`;
await fs.writeFile(ffprobe, `#!/usr/bin/env node
const fs = require('node:fs');
const counter = ${JSON.stringify(`${temp}/attempts`)};
const attempt = Number(fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8') : 0) + 1;
fs.writeFileSync(counter, String(attempt));
if (${JSON.stringify(probe)} === 'fail') {
process.stderr.write('Temporary upstream failure'); process.exit(1);
} else if (attempt === 1) {
setInterval(() => {}, 1000);
} else {
const child = require('node:child_process').spawn('ffprobe', process.argv.slice(2), {stdio: 'inherit'});
process.on('SIGTERM', () => child.kill('SIGTERM'));
child.on('exit', code => process.exit(code ?? 1));
}
`, { mode: 0o755 });
}
const child = spawn(process.execPath, ['server/index.js'], {
cwd: root, detached: true,
env: { ...process.env, PORT: String(port), PLAYBACK_CONNECTION_MODE: mode,
METADATA_PROBE_ENABLED: metadataEnabled, METADATA_PROBE_TIMEOUT_MS: '1000', FFPROBE_PATH: ffprobe,
SINGLE_CONNECTION_HOSTS: singleConnectionHosts, YT_DLP_PATH: ytDlp, YT_DLP_FORMAT: resolverFormat,
FFMPEG_INPUT_SEEKABLE: '0', FFMPEG_HTTP_RECONNECT: '0', LOCAL_VIDEOS: '',
RECENT_URLS_PATH: `${temp}/recent.json`, FAVORITES_PATH: `${temp}/favorites.json` },
stdio: ['ignore', 'pipe', 'pipe'],
});
let logs = '';
child.stdout.on('data', chunk => logs += chunk);
child.stderr.on('data', chunk => logs += chunk);
t.after(async () => {
try { process.kill(-child.pid, 'SIGTERM'); } catch {}
if (child.exitCode === null && child.signalCode === null) await once(child, 'close');
// Include any worker still shutting down in this isolated process group.
try { process.kill(-child.pid, 'SIGKILL'); } catch {}
upstream.closeAllConnections();
await new Promise(resolve => upstream.close(resolve));
});
const base = `http://127.0.0.1:${port}`;
await until(async () => {
try { return (await fetch(`${base}/api/health`)).ok; } catch { return false; }
});
return {
base, requests, logs: () => logs,
attempts: async () => Number(await fs.readFile(`${temp}/attempts`, 'utf8')),
ytDlpArgs: async () => JSON.parse(await fs.readFile(`${temp}/yt-dlp-args.json`, 'utf8')),
create: async (pathname = '/movie.mp4') => fetch(`${base}/api/session`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: pathname.startsWith('http') ? pathname : `${sourceBase}${pathname}`, fps: 12, width: 160 }),
}).then(response => response.json()),
metadata: id => fetch(`${base}/api/session/${id}`).then(response => response.json()),
};
}
function connect(t, base, id) {
const abort = new AbortController();
const socket = new WebSocket(`${base.replace('http:', 'ws:')}/frames/${id}`);
const close = () => { abort.abort(); socket.terminate(); };
t.after(close);
const timer = setTimeout(close, 10000);
const frame = new Promise((resolve, reject) => {
socket.on('message', (data, binary) => { if (binary) resolve(data); });
socket.on('error', reject);
socket.on('close', () => reject(new Error('Frames closed before first image')));
});
const audio = new Promise((resolve, reject) => {
void (async () => {
const response = await fetch(`${base}/audio/${id}`, { signal: abort.signal });
assert.equal(response.status, 200);
let bytes = 0;
for await (const chunk of response.body) {
bytes += chunk.length;
if (bytes >= 8192) resolve(bytes);
}
reject(new Error('Audio ended before enough data arrived'));
})().catch(reject);
});
return {
ready: Promise.all([frame, audio]).finally(() => clearTimeout(timer)),
close,
};
}
test('recorded MP4 starts with random access and recovers seeking after a probe timeout', { timeout: 20000 }, async t => {
const app = await startApp(t, { probe: 'timeout-once' });
const session = await app.create();
assert.equal(session.metadataStatus, 'pending');
const streams = connect(t, app.base, session.id);
const [firstFrame] = await streams.ready;
assert.equal(firstFrame.readDoubleLE(0), 0);
assert.equal(firstFrame.readUInt16BE(8), 0xffd8);
assert.match(app.logs(), /source=direct mode=split metadata=pending/);
assert.ok(app.requests.some(request => request.start > 16 * 1024 * 1024), 'ffmpeg must skip to metadata at EOF');
const metadata = await until(async () => {
const value = await app.metadata(session.id);
return value.metadataStatus === 'ready' && value;
});
assert.equal(metadata.seekable, true);
assert.ok(metadata.duration >= 8 && metadata.duration < 9);
assert.equal(await app.attempts(), 2);
assert.match(app.logs(), /duration probe retry .*ffprobe timed out/);
streams.close();
await until(() => (app.logs().match(/ffmpeg exited/g) ?? []).length === 2);
const response = await fetch(`${app.base}/api/session/${session.id}/seek`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ time: 4 }),
});
assert.equal(response.status, 200);
const sought = await response.json();
assert.equal(sought.seekSeconds, 4);
assert.equal(sought.seekGeneration, 1);
const resumed = connect(t, app.base, session.id);
const [nextFrame] = await resumed.ready;
assert.notDeepEqual(nextFrame.subarray(8), firstFrame.subarray(8), 'seek must change the video content');
resumed.close();
await until(() => (app.logs().match(/ffmpeg exited/g) ?? []).length === 4);
});
test('failed metadata retries are bounded and finish as unavailable', { timeout: 12000 }, async t => {
const app = await startApp(t, { probe: 'fail' });
const session = await app.create();
await until(async () => (await app.metadata(session.id)).metadataStatus === 'unavailable');
assert.equal(await app.attempts(), 3);
for (let i = 0; i < 3; i++) assert.equal((await app.metadata(session.id)).seekable, false);
assert.equal(await app.attempts(), 3);
});
test('live relay still uses exactly one upstream connection and no metadata probe', { timeout: 15000 }, async t => {
const app = await startApp(t, { probe: 'fail' });
const session = await app.create('/live');
assert.equal(session.metadataStatus, 'disabled');
const streams = connect(t, app.base, session.id);
await streams.ready;
assert.equal(app.requests.length, 1);
assert.equal(app.requests[0].range, false);
const recovery = await fetch(`${app.base}/api/session/${session.id}/seek`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ time: 5, allowBestEffort: true }),
});
assert.equal(recovery.status, 409);
assert.equal(app.requests.length, 1);
await assert.rejects(app.attempts(), { code: 'ENOENT' });
streams.close();
await until(() => app.logs().includes('relay closed'));
assert.equal((app.logs().match(/ffmpeg exited/g) ?? []).length, 2);
});
test('subscription hosts stay on one connection even for MP4 URLs with global split and probing enabled', { timeout: 15000 }, async t => {
const app = await startApp(t, { mode: 'split', probe: 'fail', singleConnectionHosts: '127.0.0.1', metadataEnabled: '1' });
const session = await app.create('/iptv.mp4');
assert.equal(session.metadataStatus, 'disabled');
assert.equal(session.seekable, false);
const streams = connect(t, app.base, session.id);
await streams.ready;
assert.match(app.logs(), /source=direct mode=relay metadata=disabled/);
for (const allowBestEffort of [false, true]) {
const result = await fetch(`${app.base}/api/session/${session.id}/seek`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ time: 5, allowBestEffort }),
});
assert.equal(result.status, 409);
}
assert.equal(app.requests.length, 1);
assert.equal(app.requests[0].range, false);
await assert.rejects(app.attempts(), { code: 'ENOENT' });
streams.close();
await until(() => app.logs().includes('relay closed'));
});
for (const { label, url, kind, resolver, mode = 'single' } of [
...['separate', 'combined'].map(resolver => ({ label: 'YouTube', url: 'https://www.youtube.com/watch?v=test', kind: 'youtube', resolver })),
...['separate', 'combined', 'unknown-codecs', 'playlist'].map(resolver => ({ label: 'X', url: 'https://x.com/example/status/123?s=20', kind: 'twitter', resolver, mode: 'relay' })),
{ label: 'Twitter', url: 'https://twitter.com/example/status/123/video/2', kind: 'twitter', resolver: 'combined' },
]) {
test(`${label} ${resolver} sources play and seek with Node enabled and no extra probe`, { timeout: 15000 }, async t => {
const app = await startApp(t, { mode, probe: 'fail', resolver });
const session = await app.create(url);
assert.equal(session.duration, 8);
assert.equal(session.metadataStatus, 'ready');
assert.equal(session.seekable, true);
const args = await app.ytDlpArgs();
assert.equal(args[args.indexOf('--js-runtimes') + 1], `node:${process.execPath}`);
assert.match(args[args.indexOf('--format') + 1], /bestvideo.*\+bestaudio/);
if (kind === 'twitter') {
assert.equal(args[args.indexOf('--format') + 1], 'best[protocol=https]/bestvideo[protocol=https]+bestaudio[protocol=https]');
}
assert.equal(args.at(-1), url, 'preserve the selected video and query parameters');
assert.ok(args.includes('--no-playlist'));
if (kind === 'twitter') assert.equal(args[args.indexOf('--playlist-items') + 1], '1');
const recents = await fetch(`${app.base}/api/recent-urls`).then(response => response.json());
assert.equal(recents.urls[0].url, url);
const streams = connect(t, app.base, session.id);
const [frame] = await streams.ready;
assert.ok(Number.isFinite(frame.readDoubleLE(0)));
assert.equal(frame.readUInt16BE(8), 0xffd8, 'frames must remain JPEGs');
assert.equal(frame.readUInt16BE(frame.length - 2), 0xffd9);
streams.close();
await until(() => (app.logs().match(/ffmpeg exited/g) ?? []).length === 2);
const result = await fetch(`${app.base}/api/session/${session.id}/seek`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ time: 4 }),
});
assert.equal(result.status, 200);
assert.equal((await result.json()).seekSeconds, 4);
const resumed = connect(t, app.base, session.id);
await resumed.ready;
resumed.close();
await until(() => (app.logs().match(/ffmpeg exited/g) ?? []).length === 4);
if (resolver === 'separate' || resolver === 'playlist') {
assert.ok(app.requests.some(request => request.url === '/video.mp4' && request.branch === 'video'));
assert.ok(app.requests.some(request => request.url === '/audio.m4a' && request.branch === 'audio'));
} else {
assert.ok(app.requests.every(request => request.url === '/combined.mp4'));
}
assert.ok(app.logs().includes(`source=${kind} mode=split metadata=ready`));
assert.doesNotMatch(app.logs(), /(?:video|audio|combined)-secret/);
await assert.rejects(app.attempts(), { code: 'ENOENT' });
});
}
for (const host of ['youtu.be', 'x.com', 'twitter.com']) {
test(`${host} resolution failures are logged without exposing source URLs`, async t => {
const app = await startApp(t, { resolver: 'fail' });
const session = await app.create(`https://${host}/example/status/123`);
assert.match(session.error, /yt-dlp failed to resolve/);
assert.ok(app.logs().includes(`yt-dlp resolve failed host=${host}`));
assert.doesNotMatch(app.logs(), /resolver-secret|example\.test/);
if (host !== 'youtu.be') assert.match(session.error, /X\/Twitter URL/);
});
}
test('audio-less YouTube results are rejected before any playback worker starts', async t => {
const app = await startApp(t, { resolver: 'missing-audio' });
const session = await app.create('https://youtu.be/test');
assert.match(session.error, /did not return playable video and audio streams/);
assert.equal(app.requests.length, 0);
});
test('X/Twitter desktop and mobile URLs use the resolver, with hostname boundaries enforced', async t => {
const app = await startApp(t, { resolver: 'combined', probe: 'fail' });
for (const host of ['x.com', 'www.x.com', 'mobile.x.com', 'm.x.com', 'twitter.com', 'www.twitter.com', 'mobile.twitter.com', 'm.twitter.com']) {
const url = `https://${host}/i/web/status/123`;
assert.equal((await app.create(url)).metadataStatus, 'ready');
assert.equal((await app.ytDlpArgs()).at(-1), url);
}
const lastArgs = await app.ytDlpArgs();
for (const host of ['notx.com', 'nottwitter.com', 'x.com.example.test', 'twitter.com.example.test']) {
assert.equal((await app.create(`https://${host}/example/status/123`)).metadataStatus, 'disabled');
assert.deepEqual(await app.ytDlpArgs(), lastArgs, 'unrelated hosts must not invoke yt-dlp');
}
await assert.rejects(app.attempts(), { code: 'ENOENT' });
});
test('X preserves an explicitly configured yt-dlp format selector', async t => {
const resolverFormat = 'best[protocol=https][height<=720]';
const app = await startApp(t, { resolver: 'combined', resolverFormat });
assert.equal((await app.create('https://x.com/example/status/123')).metadataStatus, 'ready');
const args = await app.ytDlpArgs();
assert.equal(args[args.indexOf('--format') + 1], resolverFormat);
});
for (const resolver of ['missing-audio', 'missing-video', 'empty-playlist']) {
test(`X ${resolver} results are rejected before playback starts`, async t => {
const app = await startApp(t, { resolver });
const session = await app.create('https://x.com/example/status/123');
assert.match(session.error, /did not return playable video and audio streams/);
assert.equal(app.requests.length, 0);
});
}
for (const resolverMetadata of [{ duration: null }, { duration: 0 }, { is_live: true }, { live_status: 'is_live' }]) {
test(`X metadata ${JSON.stringify(resolverMetadata)} does not enable seeking`, async t => {
const app = await startApp(t, { resolver: 'combined', resolverMetadata, probe: 'fail', mode: 'single' });
const session = await app.create('https://x.com/example/status/123');
assert.equal(session.duration, null);
assert.equal(session.seekable, false);
assert.equal(session.metadataStatus, 'pending');
assert.match(app.logs(), /source=twitter mode=split metadata=pending/);
});
}
test('protected page hosts bypass yt-dlp, probing, and recovery seeking', async t => {
const app = await startApp(t, { resolver: 'fail', probe: 'fail', mode: 'split', metadataEnabled: '1', singleConnectionHosts: 'X.COM, twitter.com, youtube.com' });
for (const host of ['x.com', 'mobile.x.com', 'twitter.com', 'www.youtube.com']) {
const session = await app.create(`https://${host}/example/status/123`);
assert.equal(session.metadataStatus, 'disabled');
assert.equal(session.seekable, false);
const recovery = await fetch(`${app.base}/api/session/${session.id}/seek`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ time: 5, allowBestEffort: true }),
});
assert.equal(recovery.status, 409);
}
assert.match(app.logs(), /source=direct mode=relay metadata=disabled/);
await assert.rejects(app.ytDlpArgs(), { code: 'ENOENT' });
await assert.rejects(app.attempts(), { code: 'ENOENT' });
assert.equal(app.requests.length, 0);
});
test('resolved combined sources on protected hosts use one relay connection', { timeout: 15000 }, async t => {
const app = await startApp(t, { resolver: 'protected-combined', probe: 'fail', mode: 'split', metadataEnabled: '1', singleConnectionHosts: '127.0.0.1' });
const session = await app.create('https://x.com/example/status/123');
assert.equal(session.metadataStatus, 'disabled');
assert.equal(session.duration, null);
assert.equal(session.seekable, false);
const streams = connect(t, app.base, session.id);
await streams.ready;
assert.equal(app.requests.length, 1);
assert.equal(app.requests[0].range, false);
assert.match(app.logs(), /source=twitter mode=relay metadata=disabled/);
await assert.rejects(app.attempts(), { code: 'ENOENT' });
streams.close();
await until(() => app.logs().includes('relay closed'));
});
test('resolved separate sources cannot bypass single-connection hosts', async t => {
const app = await startApp(t, { resolver: 'separate', singleConnectionHosts: '127.0.0.1' });
const session = await app.create('https://x.com/example/status/123');
assert.match(session.error, /separate connections to a single-connection host/);
assert.equal(app.requests.length, 0);
});