Cancel stale playback probes and verify Linux QSV support

This commit is contained in:
Technomancer702
2026-10-13 19:02:15 -07:00
parent cca55edc4e
commit 379971022d
22 changed files with 443 additions and 158 deletions

View File

@ -24,7 +24,7 @@ test('both subtitle menus select real HLS cues, discard stale tracks and preserv
assert.equal(generated.status, 0, generated.stderr);
const root = path.join(__dirname, '../public');
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js', '/js/components/PlayerFullscreen.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js', '/js/components/PlaybackProbe.js', '/js/components/PlayerFullscreen.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const master = names => '#EXTM3U\n' + names.map(([name, extra, language = 'en'], index) =>
`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="${name}",LANGUAGE="${language}",DEFAULT=NO,AUTOSELECT=NO,${extra}URI="/sub${index}.m3u8"\n`).join('') +
'#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS="avc1.42c00a",SUBTITLES="subs"\n/video.m3u8\n';

View File

@ -17,7 +17,7 @@ function playerFixture(name, settings, fetcher) {
const document = { getElementById() { return null; }, removeEventListener() {} };
const probe = { video: 'h264', audio: 'aac', audioChannels: 2, audioCodecs: ['aac'], needsRemux: true };
const context = { window, document, AbortController, setTimeout, clearTimeout, CustomEvent: class {},
CaptionTracks: require('../public/js/components/CaptionTracks'),
PlaybackProbe: require('../public/js/components/PlaybackProbe'), CaptionTracks: require('../public/js/components/CaptionTracks'),
VodDuration: require('../public/js/components/VodDuration'), console: { log() {}, warn() {}, error() {} },
API: { settings: { get: async () => settings } },
fetch: async (url, options) => {
@ -239,3 +239,40 @@ test('real FFmpeg probes, remuxes and transcodes only the requested MPEG-TS prog
assert.equal(output.status, 0, output.stderr);
assert.equal(JSON.parse(output.stdout).streams.find(stream => stream.codec_type === 'video').width, 96);
});
for (const name of ['VideoPlayer', 'WatchPage']) {
for (const mode of ['autoTranscode', 'forceTranscode', 'forceRemux']) {
test(`${name} ${mode} cancels probes on Stop and replacement playback`, async () => {
for (const replace of [false, true]) {
const probes = [];
const f = playerFixture(name, { [mode]: true }, (url, options) => {
probes.push({ url, options });
if (probes.length > 1) return Promise.resolve({ ok: true, json: async () => ({ audio: 'aac', audioCodecs: ['aac'], needsRemux: true }) });
return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(Object.assign(Error('Stopped'), { name: 'AbortError' })), { once: true }));
});
const first = f.start();
while (probes.length === 0) await new Promise(resolve => setImmediate(resolve));
if (replace) await f.start(); else f.player.stop();
await first;
assert.equal(probes[0].options.signal.aborted, true);
if (replace) {
assert.equal(probes.length, 2);
assert.equal(new URL(probes[1].url, 'http://app').searchParams.get('requestSequence'), '2');
assert.equal(mode === 'forceTranscode' ? f.sessions.length : Number(f.player.video.src !== ''), 1);
} else { assert.equal(f.sessions.length, 0); assert.equal(f.player.video.src, ''); }
}
});
}
}
test('browsing away from Live TV preserves its in-flight metadata probe and starts playback', async () => {
let signal, finish;
const f = playerFixture('VideoPlayer', { forceRemux: true }, (url, options) => {
signal = options.signal;
return new Promise(resolve => { finish = () => resolve({ ok: true, json: async () => ({ audioCodecs: ['aac'] }) }); });
});
const playing = f.start(); while (!finish) await new Promise(resolve => setImmediate(resolve));
const app = navigationFixture(f); app.navigateTo('guide');
assert.equal(signal.aborted, false); finish(); await playing;
assert.equal(new URL(f.player.video.src, 'http://app').pathname, '/api/remux');
});

View File

@ -37,7 +37,7 @@ test('Chromium plays program/ad/program audio with decoder resets at played boun
manifest.push('#EXT-X-ENDLIST');
const root = path.join(__dirname, '../public');
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js',
'/js/components/PlayerFullscreen.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
'/js/components/PlaybackProbe.js', '/js/components/PlayerFullscreen.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://fixture.test');
const send = (type, data) => { res.writeHead(200, { 'Content-Type': type }); res.end(data); };

View File

@ -267,7 +267,7 @@ for (const name of ['VideoPlayer', 'WatchPage']) {
loadSource() {} attachMedia() {} destroy() { this.destroyed = true; }
}
const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js';
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), { window, Hls, HlsAudioRecovery: require('../public/js/components/HlsAudioRecovery'), CaptionTracks: require('../public/js/components/CaptionTracks'), console: { log() {}, error() {} } });
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), { window, Hls, HlsAudioRecovery: require('../public/js/components/HlsAudioRecovery'), PlaybackProbe: require('../public/js/components/PlaybackProbe'), CaptionTracks: require('../public/js/components/CaptionTracks'), console: { log() {}, error() {} } });
const player = Object.create(window[name].prototype);
let plays = 0;
player.video = { play: async () => { plays++; } };

View File

@ -0,0 +1,122 @@
process.env.ALLOW_LOCAL_MEDIA_URLS = 'true';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const http = require('node:http');
const { EventEmitter } = require('node:events');
const { createRequire } = require('node:module');
const cp = require('node:child_process');
const express = require('express');
const PlaybackProbe = require('../public/js/components/PlaybackProbe');
const { ProbeLeases, parseProbeLease } = require('../server/services/probeLeases');
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
async function until(fn) { for (let n = 0; n < 300; n++) { if (fn()) return; await delay(10); } throw Error('Fixture condition timed out'); }
function router(spawn) {
const filename = path.join(__dirname, '../server/routes/probe.js'), realRequire = createRequire(filename);
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, URL, console: { log() {}, error() {} }, setTimeout, clearTimeout,
require: name => name === 'child_process' ? { spawn } : realRequire(name) }, { filename });
return module.exports;
}
async function serve(t, app) {
const server = http.createServer(app);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
return `http://127.0.0.1:${server.address().port}`;
}
test('probe leases reject invalid and late sequences, serialize cleanup and isolate accounts', async () => {
for (const query of [{ probeLeaseId: 'short', requestSequence: '1' }, { probeLeaseId: 'a'.repeat(16) },
{ probeLeaseId: ['a'.repeat(16)], requestSequence: '1' }, { probeLeaseId: 'a'.repeat(16), requestSequence: '1e2' },
{ probeLeaseId: 'a'.repeat(16), requestSequence: '0' }]) assert.throws(() => parseProbeLease(query));
assert.equal(parseProbeLease({}), null);
const leases = new ProbeLeases(), signal = new AbortController().signal, id = 'a'.repeat(16);
let release, aborted = false, secondStarted = false;
const first = leases.run(1, { id, sequence: 1 }, signal, async s => {
s.addEventListener('abort', () => { aborted = true; });
await new Promise(resolve => { release = resolve; });
});
await until(() => release);
const second = leases.run(1, { id, sequence: 2 }, signal, async () => { secondStarted = true; });
await leases.run(2, { id, sequence: 1 }, signal, async () => 'independent');
assert.equal(aborted, true); assert.equal(secondStarted, false);
await assert.rejects(leases.run(1, { id, sequence: 1 }, signal, async () => assert.fail('Late request ran')), { name: 'AbortError' });
release(); await Promise.all([first, second]); assert.equal(secondStarted, true);
await assert.rejects(leases.run(1, { id, sequence: 2 }, signal, async () => {}), { name: 'AbortError' });
});
test('browser probe cancels response bodies and retains program, user agent and increasing sequence', async () => {
const requests = []; let body;
const probe = new PlaybackProbe(async (url, options) => {
requests.push({ url: new URL(url, 'http://app'), options });
return { ok: true, json: () => requests.length === 1 ? new Promise(resolve => { body = resolve; }) : Promise.resolve({ audio: 'aac' }) };
});
const first = probe.read('http://provider/old'); const rejected = assert.rejects(first, { name: 'AbortError' });
await until(() => body);
assert.deepEqual(await probe.read('http://provider/new', { program: '102', userAgent: 'Custom Agent' }), { audio: 'aac' });
assert.equal(requests[0].options.signal.aborted, true); body({ audio: 'mp3' }); await rejected;
const query = requests[1].url.searchParams;
assert.equal(query.get('program'), '102'); assert.equal(query.get('ua'), 'Custom Agent');
assert.equal(query.get('requestSequence'), '2'); assert.equal(query.get('probeLeaseId'), requests[0].url.searchParams.get('probeLeaseId'));
});
test('probe route waits for child close before cached replacements or timeout fallback and does not cache cancellations', async t => {
const children = [], info = { streams: [{ codec_type: 'video', codec_name: 'h264' }, { codec_type: 'audio', codec_name: 'aac' }], format: { format_name: 'mpegts' } };
const app = express(); app.locals.ffprobePath = 'fixture'; app.locals.probeTimeoutMs = 150;
app.use((req, res, next) => { req.user = { id: req.headers['x-owner'] || 1 }; next(); });
app.use('/api/probe', router((binary, args) => {
const child = new EventEmitter(); Object.assign(child, { stdout: new EventEmitter(), stderr: new EventEmitter(), url: args.at(-1), closed: false });
child.kill = () => { child.killed = true; setTimeout(() => { child.closed = true; child.emit('close', null); }, 50); return true; };
children.push(child);
if (!child.url.includes('stall')) setImmediate(() => { child.stdout.emit('data', JSON.stringify(info)); child.closed = true; child.emit('close', 0); });
return child;
}));
const base = await serve(t, app), lease = 'abcdef0123456789';
const request = (url, seq, options) => fetch(base + '/api/probe?' + new URLSearchParams({ url, probeLeaseId: lease, requestSequence: String(seq) }), options);
assert.equal((await request('http://provider/cached', 1)).status, 200);
const old = request('http://provider/stall', 2); await until(() => children.length === 2);
const cached = request('http://provider/cached', 3); await until(() => children[1].killed);
assert.equal(children[1].closed, false);
assert.equal((await old).status, 409); assert.equal(children[1].closed, true);
assert.equal((await cached).status, 200); assert.equal(children.length, 2);
assert.equal((await request('http://provider/stall', 2)).status, 409); assert.equal(children.length, 2);
// Cancellation did not create a cached unknown-codec fallback.
const disconnected = new AbortController(), pending = request('http://provider/stall', 4, { signal: disconnected.signal });
const rejection = assert.rejects(pending, { name: 'AbortError' });
await until(() => children.length === 3); disconnected.abort(); await rejection;
await until(() => children[2].closed);
const started = Date.now(), timeout = await request('http://provider/stall', 5);
assert.equal(children.length, 4); assert.equal(children[3].closed, true);
assert.ok(Date.now() - started >= 190); assert.match((await timeout.json()).error, /timeout/);
// A different account can use its own sequence even with the same lease identifier.
assert.equal((await request('http://provider/cached', 1, { headers: { 'x-owner': '2' } })).status, 200);
for (const query of ['probeLeaseId[]=abc&requestSequence=1', 'probeLeaseId=abcdefghijklmnop&requestSequence=0', 'ua[]=a']) {
assert.equal((await fetch(base + '/api/probe?url=http://provider/a&' + query)).status, 400);
}
});
test('real FFprobe releases stalled provider connections on replacement, Stop and timeout', { timeout: 15000 }, async t => {
const ffprobe = cp.spawnSync('ffprobe', ['-version'], { stdio: 'ignore' }).status === 0 ? 'ffprobe' : require('@ffprobe-installer/ffprobe').path;
const app = express(), children = []; let active = 0, maxActive = 0;
app.locals.ffprobePath = ffprobe; app.locals.probeTimeoutMs = 700;
app.get('/stall/:id', (req, res) => {
active++; maxActive = Math.max(active, maxActive); res.once('close', () => active--);
res.set('Content-Type', 'video/mp2t'); res.flushHeaders();
});
app.use('/api/probe', router((...args) => {
assert.equal(children.filter(child => !child.closed).length, 0, 'New probe started before old process closed');
const child = cp.spawn(...args); children.push(child); child.on('close', () => { child.closed = true; }); return child;
}));
t.after(() => { for (const child of children) if (!child.closed) child.kill('SIGKILL'); });
const base = await serve(t, app), probe = new PlaybackProbe((url, options) => fetch(base + url, options));
const first = probe.read(base + '/stall/first'); const firstRejected = assert.rejects(first, { name: 'AbortError' });
await until(() => active === 1);
const second = probe.read(base + '/stall/second'); const secondRejected = assert.rejects(second, { name: 'AbortError' });
await firstRejected; await until(() => children.length === 2 && active === 1);
probe.cancel(); await secondRejected; await until(() => active === 0 && children.every(child => child.closed));
const fallback = await probe.read(base + '/stall/timeout');
assert.match(fallback.error, /timeout/); await until(() => active === 0);
assert.ok(children.every(child => child.closed)); assert.equal(maxActive, 1);
});

View File

@ -0,0 +1,54 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createRequire } = require('node:module');
function fixture({ nodes = [], vendors = {}, inaccessible = [], pci = '', fail = false, platform = 'linux' } = {}) {
const calls = [], filename = path.join(__dirname, '../server/services/hwDetect.js'), module = { exports: {} };
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, console: { log() {} }, require: name => ({
os: { platform: () => platform }, fs: { constants: fs.constants, readdirSync: () => nodes,
accessSync: device => { if (inaccessible.includes(device)) throw Error('EACCES'); },
readFileSync: file => { const name = file.split('/')[4]; if (!vendors[name]) throw Error('ENOENT'); return vendors[name]; } },
child_process: { execSync: command => { calls.push({ command }); return pci; },
execFile: (binary, args, options, callback) => { calls.push({ binary, args, options }); callback(fail ? Error('Encode unavailable or timeout') : null); } }
})[name] }, { filename });
return { hw: module.exports, calls };
}
test('QSV verifies a mapped Intel device with the configured FFmpeg and bounded H.264 encode', async () => {
const f = fixture({ nodes: ['card0', 'renderD129', 'renderD128'], vendors: { renderD129: '0x8086\n', renderD128: '0x1002' } });
const qsv = await f.hw.detectQuickSync('/configured/ffmpeg');
assert.equal(qsv.available, true); assert.equal(qsv.device, '/dev/dri/renderD129');
assert.equal(f.calls.length, 1); const call = f.calls[0];
assert.equal(call.binary, '/configured/ffmpeg'); assert.equal(call.options.timeout, 5000);
assert.equal(call.options.windowsHide, true); assert.equal(call.options.maxBuffer, 262144);
assert.ok(call.args.includes('qsv=probe:hw,child_device=/dev/dri/renderD129,child_device_type=vaapi'));
assert.ok(call.args.includes('h264_qsv')); assert.ok(call.args.includes('format=nv12,hwupload=extra_hw_frames=16'));
});
test('QSV rejects unmapped, AMD, inaccessible and unusable Intel devices', async () => {
for (const options of [{ pci: 'VGA Intel' }, { nodes: ['renderD128'], vendors: { renderD128: '0x1002' }, pci: 'VGA Intel' },
{ nodes: ['renderD128'], vendors: { renderD128: '0x8086' }, inaccessible: ['/dev/dri/renderD128'] },
{ nodes: ['renderD128'], vendors: { renderD128: '0x8086' }, fail: true },
{ nodes: ['renderD128', 'renderD129'], pci: 'VGA Intel' }]) {
const f = fixture(options); assert.equal((await f.hw.detectQuickSync()).available, false);
if (!options.fail) assert.equal(f.calls.filter(call => call.binary).length, 0);
}
});
test('QSV handles a hidden sysfs vendor with PCI fallback and preserves Windows detection', async () => {
const f = fixture({ nodes: ['renderD128'], pci: '00:02.0 VGA compatible controller: Intel Corporation UHD Graphics' });
assert.equal((await f.hw.detectQuickSync()).available, true); assert.equal(f.calls[0].command, 'lspci');
const noIntel = fixture({ nodes: ['renderD128'], pci: 'VGA AMD Radeon' }); assert.equal((await noIntel.hw.detectQuickSync()).available, false);
const windows = fixture({ platform: 'win32', pci: 'Name\nIntel UHD Graphics' });
assert.equal((await windows.hw.detectQuickSync()).available, true); assert.equal(windows.calls.length, 1);
});
test('QSV sessions use the detected render node and keep Windows or forced defaults', () => {
const filename = path.join(__dirname, '../server/services/transcodeSession.js'), realRequire = createRequire(filename), module = { exports: {} };
let device = '/dev/dri/renderD129';
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, process, console, setTimeout, clearTimeout, setInterval, clearInterval,
require: name => name === './hwDetect' ? { getCapabilities: () => ({ qsv: { device } }) } : realRequire(name) }, { filename });
const session = Object.create(module.exports.TranscodeSession.prototype), args = [];
session.addHwAccelInputArgs(args, 'qsv');
assert.ok(args.includes('qsv=nodecast:hw,child_device=/dev/dri/renderD129,child_device_type=vaapi'));
assert.ok(args.includes('nodecast')); device = undefined;
const defaults = []; session.addHwAccelInputArgs(defaults, 'qsv'); assert.deepEqual(defaults, ['-hwaccel', 'qsv', '-hwaccel_output_format', 'qsv']);
});

View File

@ -21,7 +21,7 @@ test('probe preserves every audio track index including unknown codecs', () => {
function playerFixture(name, settings, probe) {
const window = { dispatchEvent() {} };
const context = { window, HlsAudioRecovery: require('../public/js/components/HlsAudioRecovery'), CaptionTracks: require('../public/js/components/CaptionTracks'), VodDuration: require('../public/js/components/VodDuration'), console: { log() {}, warn() {}, error() {} },
const context = { window, HlsAudioRecovery: require('../public/js/components/HlsAudioRecovery'), PlaybackProbe: require('../public/js/components/PlaybackProbe'), CaptionTracks: require('../public/js/components/CaptionTracks'), VodDuration: require('../public/js/components/VodDuration'), console: { log() {}, warn() {}, error() {} },
CustomEvent: class {}, document: { getElementById() { return null; } },
API: { settings: { get: async () => settings } }, fetch: probe };
const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js';