448 lines
22 KiB
JavaScript
448 lines
22 KiB
JavaScript
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);
|
||
|
|
});
|