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:
+148
-42
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user