Cancel stale playback probes and verify Linux QSV support
This commit is contained in:
@ -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';
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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); };
|
||||
|
||||
@ -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++; } };
|
||||
|
||||
122
test/probe-cancellation.test.js
Normal file
122
test/probe-cancellation.test.js
Normal 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);
|
||||
});
|
||||
54
test/qsv-detection.test.js
Normal file
54
test/qsv-detection.test.js
Normal 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']);
|
||||
});
|
||||
@ -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';
|
||||
|
||||
Reference in New Issue
Block a user