Cancel stale playback probes and verify Linux QSV support
This commit is contained in:
@ -243,7 +243,7 @@ const server = app.listen(PORT, async () => {
|
||||
// Detect hardware acceleration capabilities
|
||||
try {
|
||||
const hwDetect = require('./services/hwDetect');
|
||||
await hwDetect.detect();
|
||||
await hwDetect.detect(app.locals.ffmpegPath || 'ffmpeg');
|
||||
} catch (err) {
|
||||
console.warn('Hardware detection failed:', err.message);
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
const { ProbeLeases, parseProbeLease, requestCancellation, cancelled } = require('../services/probeLeases');
|
||||
const probeLeases = new ProbeLeases();
|
||||
const { parseProgram, selectProgramStreams } = require('../services/mpegTsProgram');
|
||||
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
|
||||
const express = require('express');
|
||||
@ -30,7 +32,7 @@ const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
|
||||
/**
|
||||
* Probe stream with ffprobe
|
||||
*/
|
||||
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-v', 'error',
|
||||
@ -50,35 +52,28 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||
url
|
||||
];
|
||||
|
||||
const proc = spawn(ffprobePath, args);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (signal?.aborted) { reject(cancelled()); return; }
|
||||
const proc = spawn(ffprobePath, args, { windowsHide: true });
|
||||
let stdout = '', stderr = '', failure;
|
||||
const stop = error => {
|
||||
if (failure) return;
|
||||
failure = error;
|
||||
proc.kill('SIGKILL');
|
||||
reject(new Error('Probe timeout'));
|
||||
}, timeout);
|
||||
|
||||
proc.stdout.on('data', (data) => { stdout += data; });
|
||||
proc.stderr.on('data', (data) => { stderr += data; });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
};
|
||||
const abort = () => stop(cancelled());
|
||||
const timer = setTimeout(() => stop(new Error('Probe timeout')), timeout);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
proc.stdout.on('data', data => { stdout += data; });
|
||||
proc.stderr.on('data', data => { stderr = (stderr + data).slice(-65536); });
|
||||
proc.on('error', error => { failure ||= error; });
|
||||
proc.once('close', code => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
reject(new Error(`ffprobe exited with code ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = JSON.parse(stdout);
|
||||
resolve(result);
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse ffprobe output'));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (failure) { reject(failure); return; }
|
||||
if (code !== 0) { reject(new Error(`ffprobe exited with code ${code}: ${redactText(stderr)}`)); return; }
|
||||
try { resolve(JSON.parse(stdout)); }
|
||||
catch { reject(new Error('Failed to parse ffprobe output')); }
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -154,68 +149,45 @@ function analyzeProbeResult(probeResult, url, program = null) {
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { url, ua } = req.query;
|
||||
let program;
|
||||
try { program = parseProgram(req.query.program); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
|
||||
const ffprobePath = req.app.locals.ffprobePath;
|
||||
const cacheKey = JSON.stringify([url, ua || '', program]);
|
||||
|
||||
if (!ffprobePath) {
|
||||
// No ffprobe available - assume needs transcoding to be safe
|
||||
console.log('[Probe] FFprobe not available, assuming transcode needed');
|
||||
return res.json({
|
||||
video: 'unknown',
|
||||
audio: 'unknown',
|
||||
container: 'unknown',
|
||||
compatible: false,
|
||||
needsRemux: false,
|
||||
needsTranscode: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
||||
console.log(`[Probe] Cache hit for: ${redactUrl(url)}...`);
|
||||
return res.json(cached.result);
|
||||
}
|
||||
|
||||
console.log(`[Probe] Probing: ${redactUrl(url)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||
|
||||
let program, lease;
|
||||
try {
|
||||
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||
const analysis = analyzeProbeResult(probeResult, url, program);
|
||||
program = parseProgram(req.query.program);
|
||||
lease = parseProbeLease(req.query);
|
||||
validateHttpUrl(url);
|
||||
if (ua !== undefined && (typeof ua !== 'string' || ua.length > 1024 || /[\x00-\x1f\x7f]/.test(ua))) throw new Error('Invalid probe user agent');
|
||||
} catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
|
||||
// Cache result
|
||||
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
|
||||
|
||||
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
|
||||
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
||||
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
|
||||
|
||||
res.json(analysis);
|
||||
} catch (err) {
|
||||
console.error('[Probe] Failed:', err.message);
|
||||
|
||||
// On error, assume transcode needed to be safe
|
||||
const fallback = {
|
||||
video: 'unknown',
|
||||
audio: 'unknown',
|
||||
container: 'unknown',
|
||||
compatible: false,
|
||||
needsRemux: false,
|
||||
needsTranscode: true,
|
||||
error: err.message
|
||||
};
|
||||
|
||||
probeCache.set(cacheKey, { result: fallback, timestamp: Date.now() - (CACHE_TTL - 30000) });
|
||||
|
||||
res.json(fallback);
|
||||
}
|
||||
const cancellation = requestCancellation(req, res);
|
||||
const cacheKey = JSON.stringify([url, ua || '', program]);
|
||||
try {
|
||||
const result = await probeLeases.run(req.user?.id ?? null, lease, cancellation.signal, async signal => {
|
||||
const ffprobePath = req.app.locals.ffprobePath;
|
||||
if (!ffprobePath) return { video: 'unknown', audio: 'unknown', container: 'unknown', compatible: false, needsRemux: false, needsTranscode: true };
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) return cached.result;
|
||||
console.log(`[Probe] Probing: ${redactUrl(url)}...`);
|
||||
try {
|
||||
const data = await probeStream(url, ffprobePath, ua, req.app.locals.probeTimeoutMs || 15000, signal);
|
||||
if (signal.aborted) throw cancelled();
|
||||
const analysis = analyzeProbeResult(data, url, program);
|
||||
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
if (signal.aborted || error.name === 'AbortError') throw cancelled();
|
||||
const message = redactText(error.message);
|
||||
console.error('[Probe] Failed:', message);
|
||||
const fallback = { video: 'unknown', audio: 'unknown', container: 'unknown', compatible: false,
|
||||
needsRemux: false, needsTranscode: true, error: message };
|
||||
probeCache.set(cacheKey, { result: fallback, timestamp: Date.now() - (CACHE_TTL - 30000) });
|
||||
return fallback;
|
||||
}
|
||||
});
|
||||
if (!cancellation.signal.aborted && !res.destroyed) res.json(result);
|
||||
} catch (error) {
|
||||
if (cancellation.signal.aborted || res.destroyed) return;
|
||||
if (error.name === 'AbortError') return res.status(409).json({ error: 'Playback probe was superseded' });
|
||||
res.status(error.code === 'PROBE_LIMIT' ? 503 : 500).json({ error: 'Unable to probe stream' });
|
||||
} finally { cancellation.dispose(); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -82,7 +82,7 @@ router.get('/hw-info', async (req, res) => {
|
||||
|
||||
// If not yet detected, run detection now
|
||||
if (!capabilities) {
|
||||
capabilities = await hwDetect.detect();
|
||||
capabilities = await hwDetect.detect(req.app.locals.ffmpegPath || 'ffmpeg');
|
||||
}
|
||||
|
||||
res.json(capabilities);
|
||||
@ -99,7 +99,7 @@ router.get('/hw-info', async (req, res) => {
|
||||
router.post('/hw-info/refresh', async (req, res) => {
|
||||
try {
|
||||
const hwDetect = require('../services/hwDetect');
|
||||
const capabilities = await hwDetect.refresh();
|
||||
const capabilities = await hwDetect.refresh(req.app.locals.ffmpegPath || 'ffmpeg');
|
||||
res.json(capabilities);
|
||||
} catch (err) {
|
||||
console.error('Error refreshing hardware info:', err);
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
* Results are cached at startup and exposed via API.
|
||||
*/
|
||||
|
||||
const { execSync, exec } = require('child_process');
|
||||
const { execSync, execFile } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// Cache detection results
|
||||
@ -118,46 +119,51 @@ async function detectVAAPI() {
|
||||
* Detect Intel QuickSync support
|
||||
* Checks if FFmpeg can use QSV
|
||||
*/
|
||||
async function detectQuickSync() {
|
||||
async function detectQuickSync(ffmpegPath = 'ffmpeg') {
|
||||
try {
|
||||
// Check if Intel GPU exists
|
||||
let hasIntelGpu = false;
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
// Windows: Check via WMIC
|
||||
const result = execSync(
|
||||
'wmic path win32_VideoController get name',
|
||||
{ timeout: 5000, encoding: 'utf-8', windowsHide: true }
|
||||
);
|
||||
hasIntelGpu = result.toLowerCase().includes('intel');
|
||||
} else if (os.platform() === 'linux') {
|
||||
// Linux: Check lspci
|
||||
const result = execSync('wmic path win32_VideoController get name',
|
||||
{ timeout: 5000, encoding: 'utf-8', windowsHide: true });
|
||||
return result.toLowerCase().includes('intel')
|
||||
? { available: true, encoder: 'h264_qsv', decoder: 'h264_qsv' }
|
||||
: { available: false, reason: 'No Intel GPU found' };
|
||||
}
|
||||
if (os.platform() !== 'linux') return { available: false, reason: 'Unsupported QSV platform' };
|
||||
|
||||
const nodes = fs.readdirSync('/dev/dri').filter(name => /^renderD\d+$/.test(name));
|
||||
const intel = [], unknown = [];
|
||||
for (const name of nodes) {
|
||||
const device = '/dev/dri/' + name;
|
||||
try { fs.accessSync(device, fs.constants.R_OK | fs.constants.W_OK); } catch { continue; }
|
||||
try {
|
||||
const result = execSync('lspci | grep -i "vga\\|display" | grep -i intel', {
|
||||
timeout: 5000,
|
||||
encoding: 'utf-8',
|
||||
shell: true
|
||||
});
|
||||
hasIntelGpu = result.trim().length > 0;
|
||||
} catch {
|
||||
hasIntelGpu = false;
|
||||
const vendor = fs.readFileSync('/sys/class/drm/' + name + '/device/vendor', 'utf8').trim().toLowerCase();
|
||||
if (vendor === '0x8086') intel.push(device);
|
||||
} catch { unknown.push(device); }
|
||||
}
|
||||
// Some containers hide sysfs. Only use the PCI fallback for a single unclassified mapped device.
|
||||
if (!intel.length && nodes.length === 1 && unknown.length === 1) {
|
||||
try {
|
||||
const pci = execSync('lspci', { timeout: 2000, encoding: 'utf8', windowsHide: true });
|
||||
if (pci.split('\n').some(line => /VGA|display|3D/i.test(line) && /intel/i.test(line))) intel.push(unknown[0]);
|
||||
} catch { /* No PCI utility or Intel adapter visible. */ }
|
||||
}
|
||||
for (const device of intel) {
|
||||
const usable = await new Promise(resolve => {
|
||||
execFile(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
|
||||
'-init_hw_device', 'qsv=probe:hw,child_device=' + device + ',child_device_type=vaapi',
|
||||
'-filter_hw_device', 'probe', '-f', 'lavfi', '-i', 'color=s=64x64:r=1',
|
||||
'-vf', 'format=nv12,hwupload=extra_hw_frames=16', '-c:v', 'h264_qsv',
|
||||
'-frames:v', '1', '-f', 'null', '-'],
|
||||
{ timeout: 5000, killSignal: 'SIGKILL', windowsHide: true, maxBuffer: 256 * 1024 }, error => resolve(!error));
|
||||
});
|
||||
if (usable) {
|
||||
console.log('[HwDetect] QuickSync encode verified on ' + device);
|
||||
return { available: true, device, encoder: 'h264_qsv', decoder: 'h264_qsv' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasIntelGpu) {
|
||||
return { available: false, reason: 'No Intel GPU found' };
|
||||
}
|
||||
|
||||
console.log('[HwDetect] Intel GPU detected, QSV may be available');
|
||||
|
||||
return {
|
||||
available: true,
|
||||
encoder: 'h264_qsv',
|
||||
decoder: 'h264_qsv'
|
||||
};
|
||||
} catch (err) {
|
||||
console.log('[HwDetect] QuickSync not available');
|
||||
return { available: false };
|
||||
return { available: false, reason: intel.length ? 'QSV encode test failed' : 'No accessible Intel render device found' };
|
||||
} catch {
|
||||
return { available: false, reason: 'QuickSync not available' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,7 +220,7 @@ async function detectAMF() {
|
||||
* Detect all hardware capabilities
|
||||
* Results are cached for performance
|
||||
*/
|
||||
async function detect() {
|
||||
async function detect(ffmpegPath) {
|
||||
if (hwCapabilities !== null) {
|
||||
return hwCapabilities;
|
||||
}
|
||||
@ -224,7 +230,7 @@ async function detect() {
|
||||
const [nvidia, vaapi, qsv, amf] = await Promise.all([
|
||||
detectNvidia(),
|
||||
detectVAAPI(),
|
||||
detectQuickSync(),
|
||||
detectQuickSync(ffmpegPath),
|
||||
detectAMF()
|
||||
]);
|
||||
|
||||
@ -265,9 +271,9 @@ function getCapabilities() {
|
||||
/**
|
||||
* Force re-detection (clears cache)
|
||||
*/
|
||||
async function refresh() {
|
||||
async function refresh(ffmpegPath) {
|
||||
hwCapabilities = null;
|
||||
return detect();
|
||||
return detect(ffmpegPath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
56
server/services/probeLeases.js
Normal file
56
server/services/probeLeases.js
Normal file
@ -0,0 +1,56 @@
|
||||
function cancelled() { return Object.assign(new Error('Playback probe was superseded'), { name: 'AbortError' }); }
|
||||
|
||||
function parseProbeLease(query) {
|
||||
if (query.probeLeaseId === undefined && query.requestSequence === undefined) return null;
|
||||
if (typeof query.probeLeaseId !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(query.probeLeaseId) ||
|
||||
typeof query.requestSequence !== 'string' || !/^[1-9]\d{0,14}$/.test(query.requestSequence)) {
|
||||
throw new Error('Invalid probe lease or request sequence');
|
||||
}
|
||||
return { id: query.probeLeaseId, sequence: Number(query.requestSequence) };
|
||||
}
|
||||
|
||||
function requestCancellation(req, res) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => { if (!res.writableEnded) controller.abort(); };
|
||||
res.once('close', abort);
|
||||
if (req.aborted || res.destroyed) controller.abort();
|
||||
return { signal: controller.signal, dispose: () => res.off('close', abort) };
|
||||
}
|
||||
|
||||
class ProbeLeases {
|
||||
constructor() { this.entries = new Map(); }
|
||||
run(ownerId, lease, signal, task) {
|
||||
if (signal.aborted) return Promise.reject(cancelled());
|
||||
if (!lease) return task(signal);
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (!entry.job && now - entry.touched > 60000) this.entries.delete(key);
|
||||
}
|
||||
const key = JSON.stringify([ownerId, lease.id]);
|
||||
const previous = this.entries.get(key);
|
||||
if (previous && lease.sequence <= previous.sequence) return Promise.reject(cancelled());
|
||||
if (!previous && this.entries.size >= 1024) {
|
||||
const idle = Array.from(this.entries).find(([, entry]) => !entry.job);
|
||||
if (idle) this.entries.delete(idle[0]);
|
||||
else return Promise.reject(Object.assign(new Error('Too many active probes'), { code: 'PROBE_LIMIT' }));
|
||||
}
|
||||
const controller = new AbortController(), job = { controller };
|
||||
const entry = { sequence: lease.sequence, touched: now, job };
|
||||
this.entries.set(key, entry);
|
||||
const abort = () => controller.abort();
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
previous?.job?.controller.abort();
|
||||
// Wait for the previous process's close event, including when this request will use cached metadata.
|
||||
job.done = Promise.resolve().then(async () => {
|
||||
if (previous?.job) await previous.job.done.catch(() => {});
|
||||
if (controller.signal.aborted || this.entries.get(key) !== entry) throw cancelled();
|
||||
return task(controller.signal);
|
||||
}).finally(() => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (this.entries.get(key) === entry) { entry.job = null; entry.touched = Date.now(); }
|
||||
});
|
||||
return job.done;
|
||||
}
|
||||
}
|
||||
module.exports = { ProbeLeases, parseProbeLease, requestCancellation, cancelled };
|
||||
@ -331,13 +331,19 @@ class TranscodeSession extends EventEmitter {
|
||||
'-hwaccel_output_format', 'vaapi'
|
||||
);
|
||||
break;
|
||||
case 'qsv':
|
||||
case 'qsv': {
|
||||
const device = hwDetect.getCapabilities()?.qsv?.device;
|
||||
if (typeof device === 'string' && /^\/dev\/dri\/renderD\d+$/.test(device)) {
|
||||
args.push('-init_hw_device', 'qsv=nodecast:hw,child_device=' + device + ',child_device_type=vaapi',
|
||||
'-filter_hw_device', 'nodecast', '-hwaccel_device', 'nodecast');
|
||||
}
|
||||
// Intel QuickSync hardware decoding
|
||||
args.push(
|
||||
'-hwaccel', 'qsv',
|
||||
'-hwaccel_output_format', 'qsv'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'amf':
|
||||
// AMD AMF (no hwaccel input, AMF is encode-only)
|
||||
// Decode on CPU, encode on GPU
|
||||
|
||||
Reference in New Issue
Block a user