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
+148 -42
View File
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import express from 'express';
@@ -20,7 +21,10 @@ const PORT = Number(process.env.PORT ?? 3000);
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
const FFPROBE_PATH = process.env.FFPROBE_PATH ?? 'ffprobe';
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 FFMPEG_LOG_LEVEL = process.env.FFMPEG_LOG_LEVEL ?? 'warning';
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_CAPABILITY_TIMEOUT_MS = 3000;
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_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_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
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 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,
originalUrl: source.originalUrl,
url: source.url,
});
const metadataStatus = shouldProbeMetadata ? 'pending' : 'disabled';
}));
const metadataStatus = duration !== null ? 'ready' : shouldProbeMetadata ? 'pending' : 'disabled';
const session = {
id,
url: source.url,
originalUrl: source.originalUrl,
sourceKind: source.kind,
sourceHeaders: source.headers,
audioSource: source.audioSource ?? null,
singleConnection: source.singleConnection ?? false,
options,
duration: null,
duration,
metadataStatus,
metadataProbePromise: null,
seekSeconds: 0,
@@ -272,7 +282,7 @@ app.post('/api/session/:sessionId/seek', async (request, response) => {
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);
}
@@ -334,7 +344,9 @@ app.all('/_source/:token', async (request, response) => {
response.on('close', cleanup);
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');
if (range) {
@@ -343,7 +355,7 @@ app.all('/_source/:token', async (request, response) => {
headers.set('accept-encoding', 'identity');
const upstream = await fetch(session.url, {
const upstream = await fetch(input.url, {
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
headers,
redirect: 'follow',
@@ -704,44 +716,75 @@ function parseResolvedMediaUrl(value) {
}
async function resolvePlaybackSource(url) {
if (!isYouTubeUrl(url)) {
const singleConnection = requiresSingleConnection(url);
const kind = singleConnection ? null : getYtDlpSourceKind(url);
if (!kind) {
return {
kind: 'direct',
url,
originalUrl: url,
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 {
kind: 'youtube',
kind,
url: resolved.url,
originalUrl: url,
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;
try {
parsed = new URL(value);
} catch {
return false;
return null;
}
const hostname = parsed.hostname.toLowerCase();
return hostname === 'youtu.be'
if (hostname === 'youtu.be'
|| hostname.endsWith('.youtu.be')
|| hostname === 'youtube.com'
|| hostname.endsWith('.youtube.com')
|| hostname === 'youtube-nocookie.com'
|| hostname.endsWith('.youtube-nocookie.com');
|| hostname.endsWith('.youtube-nocookie.com')) {
return 'youtube';
}
if (/^(?:(?:www|m|mobile)\.)?(?:x|twitter)\.com$/.test(hostname)) {
return 'twitter';
}
return null;
}
async function resolveYouTubeSource(url) {
function isYtDlpSourceKind(kind) {
return kind === 'youtube' || kind === 'twitter';
}
async function resolveYtDlpSource(url, kind) {
const startedAt = Date.now();
logInfo(`yt-dlp resolving source host=${new URL(url).hostname}`);
@@ -750,29 +793,52 @@ async function resolveYouTubeSource(url) {
try {
payload = await runYtDlpJson([
'--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-progress',
'--dump-single-json',
'--js-runtimes',
`node:${process.execPath}`,
'--format',
YT_DLP_FORMAT,
kind === 'twitter' ? YT_DLP_TWITTER_FORMAT : YT_DLP_FORMAT,
url,
]);
} 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 (!mediaUrl) {
throw new Error('yt-dlp did not return a playable media URL.');
if (Array.isArray(payload?.entries)) {
payload = payload.entries[0];
}
const parsed = parseResolvedMediaUrl(mediaUrl);
logInfo(`yt-dlp resolved source host=${new URL(parsed).hostname} durationMs=${Date.now() - startedAt}`);
const formats = Array.isArray(payload?.requested_formats) && payload.requested_formats.length > 0
? 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 {
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) {
return hasRecordedDuration(session);
return !session.singleConnection && hasRecordedDuration(session);
}
function hasRecordedDuration(session) {
@@ -973,8 +1039,11 @@ function hasRecordedDuration(session) {
}
function canBestEffortResumeWithoutDuration(session) {
return !session.singleConnection && (isYtDlpSourceKind(session.sourceKind) || isLikelyRecordedSession(session));
}
function isLikelyRecordedSession(session) {
return session.sourceKind === 'local'
|| session.sourceKind === 'youtube'
|| isLikelyRecordedMediaUrl(session.originalUrl)
|| isLikelyRecordedMediaUrl(session.url);
}
@@ -995,7 +1064,13 @@ function isJellyfinRecordedMediaPath(pathname) {
}
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';
}
@@ -1003,7 +1078,9 @@ function getSessionPlaybackConnectionMode(session) {
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';
}
@@ -1015,6 +1092,10 @@ function startSessionMetadataProbe(session) {
}
function ensureSessionDuration(session) {
if (session.singleConnection) {
return Promise.resolve(null);
}
if (hasRecordedDuration(session)) {
return Promise.resolve(session.duration);
}
@@ -1025,7 +1106,7 @@ function ensureSessionDuration(session) {
session.metadataStatus = 'pending';
const probePromise = probeSessionDuration(session).then((duration) => {
const probePromise = probeSessionDurationWithRetry(session).then((duration) => {
const current = sessions.get(session.id);
if (current !== session) {
@@ -1034,6 +1115,9 @@ function ensureSessionDuration(session) {
session.duration = duration;
session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable';
if (Number.isFinite(duration)) {
logInfo(`duration ready id=${shortId(session.id)} seconds=${duration.toFixed(3)}`);
}
return duration;
}).catch((error) => {
const current = sessions.get(session.id);
@@ -1055,7 +1139,28 @@ function ensureSessionDuration(session) {
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');
try {
@@ -1067,7 +1172,7 @@ async function probeSessionDuration(session) {
'-of',
'json',
source.url,
]);
], timeoutMs);
const payload = JSON.parse(output);
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) => {
const ffprobe = spawn(FFPROBE_PATH, args, {
stdio: ['ignore', 'pipe', 'pipe'],
@@ -1100,7 +1205,7 @@ function runFfprobe(args) {
const timer = setTimeout(() => {
timedOut = true;
stopProcess(ffprobe);
}, METADATA_PROBE_TIMEOUT_MS);
}, timeoutMs);
timer.unref();
ffprobe.stdout.on('data', (chunk) => {
@@ -1791,7 +1896,7 @@ function createRelayPlayback(session) {
sourceController = new AbortController();
try {
const headers = createSourceRequestHeaders(session);
const headers = createSourceRequestHeaders(session.sourceHeaders);
headers.set('accept-encoding', 'identity');
const upstream = await fetch(session.url, {
@@ -2758,7 +2863,7 @@ function buildRelayFrameArgs(session) {
function buildPlaybackArgs(session, inputUrl) {
return [
...buildInputArgs(inputUrl, { startTime: session.seekSeconds }),
...buildInputArgs(session, inputUrl),
'-map',
'0:a:0?',
'-vn',
@@ -2781,7 +2886,7 @@ function buildPlaybackArgs(session, inputUrl) {
function buildAudioArgs(session, inputUrl, inputOptions) {
return [
...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }),
...buildInputArgs(session, inputUrl, inputOptions),
'-map',
'0:a:0?',
'-vn',
@@ -2801,14 +2906,14 @@ function buildAudioArgs(session, inputUrl, inputOptions) {
function buildFrameArgs(session, inputUrl, inputOptions) {
return [
...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }),
...buildInputArgs(session, inputUrl, inputOptions),
'-map',
'0:v:0',
...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 args = [
'-hide_banner',
@@ -2819,7 +2924,8 @@ function buildInputArgs(inputUrl, { seekable = true, startTime = 0 } = {}) {
];
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) {
@@ -3120,10 +3226,10 @@ function createLineLogger(callback) {
};
}
function createSourceRequestHeaders(session) {
function createSourceRequestHeaders(sourceHeaders) {
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);
}