Cancel stale playback probes and verify Linux QSV support
This commit is contained in:
2
.github/workflows/container.yml
vendored
2
.github/workflows/container.yml
vendored
@ -25,6 +25,8 @@ jobs:
|
||||
for attempt in $(seq 1 60); do
|
||||
if docker exec nodecast-smoke node scripts/healthcheck.cjs; then
|
||||
docker exec nodecast-smoke sh -c 'test ! -e /app/.env.container-sentinel && test ! -e /app/data/container-sentinel'
|
||||
docker exec nodecast-smoke lspci --version
|
||||
docker exec nodecast-smoke node -e "require('./server/services/hwDetect').detectQuickSync('ffmpeg').then(q => { if (q.available) process.exit(1); })"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
|
||||
@ -26,6 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
ffmpeg \
|
||||
pciutils \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
|
||||
@ -120,6 +120,8 @@ Ensure you have the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacente
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
On Linux, QSV detection checks an accessible Intel render device and verifies a short H.264 encode with the configured FFmpeg. If that check fails, QSV is not automatically recommended. Ensure the container has read/write permission on the mapped device. The image includes `pciutils` for systems where GPU vendor information is hidden in sysfs.
|
||||
|
||||
After restarting the container, go to **Settings -> Transcoding**. The **Hardware Detection** status should list your GPU (e.g., "NVIDIA GPU Detected" or "VAAPI Available").
|
||||
|
||||
### SSO / OIDC Setup
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.2.4",
|
||||
"version": "2.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.2.4",
|
||||
"version": "2.2.5",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.2.4",
|
||||
"version": "2.2.5",
|
||||
"description": "Modern IPTV Player",
|
||||
"license": "GPL-3.0-only",
|
||||
"main": "server/index.js",
|
||||
|
||||
@ -1107,8 +1107,9 @@
|
||||
<script src="/js/components/VodDuration.js?v=1"></script>
|
||||
<script src="/js/components/CaptionTracks.js?v=2"></script>
|
||||
<script src="/js/components/HlsAudioRecovery.js?v=1"></script>
|
||||
<script src="/js/components/PlaybackProbe.js?v=1"></script>
|
||||
<script src="/js/components/PlayerFullscreen.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=7"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=8"></script>
|
||||
<script src="/js/components/ChannelList.js?v=4"></script>
|
||||
<script src="/js/components/SourceManager.js?v=3"></script>
|
||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||
@ -1118,7 +1119,7 @@
|
||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
<script src="/js/pages/Settings.js?v=2"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=7"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=8"></script>
|
||||
<script src="/js/app.js?v=5"></script>
|
||||
|
||||
</body>
|
||||
|
||||
28
public/js/components/PlaybackProbe.js
Normal file
28
public/js/components/PlaybackProbe.js
Normal file
@ -0,0 +1,28 @@
|
||||
/** Metadata probes belong to the active playback, and share an ordered lease across replacements. */
|
||||
class PlaybackProbe {
|
||||
constructor(fetchImpl = fetch) {
|
||||
this.fetch = fetchImpl;
|
||||
this.sequence = 0;
|
||||
this.leaseId = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)),
|
||||
byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
async read(url, { program = null, userAgent } = {}) {
|
||||
this.cancel();
|
||||
const controller = this.controller = new AbortController();
|
||||
const query = new URLSearchParams({ url, probeLeaseId: this.leaseId, requestSequence: String(++this.sequence) });
|
||||
if (program != null) query.set('program', program);
|
||||
if (userAgent !== undefined) query.set('ua', userAgent);
|
||||
try {
|
||||
const response = await this.fetch('/api/probe?' + query, { signal: controller.signal, credentials: 'same-origin' });
|
||||
const info = await response.json();
|
||||
if (controller.signal.aborted || this.controller !== controller || response.status === 409) {
|
||||
throw Object.assign(new Error('Playback probe was superseded'), { name: 'AbortError' });
|
||||
}
|
||||
if (response.ok === false) throw new Error(info.error || 'Unable to probe stream');
|
||||
return info;
|
||||
} finally { if (this.controller === controller) this.controller = null; }
|
||||
}
|
||||
cancel() { this.controller?.abort(); this.controller = null; }
|
||||
}
|
||||
if (typeof window !== 'undefined') window.PlaybackProbe = PlaybackProbe;
|
||||
if (typeof module !== 'undefined') module.exports = PlaybackProbe;
|
||||
@ -643,7 +643,6 @@ class VideoPlayer {
|
||||
}
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
this.currentChannel = channel;
|
||||
const programQuery = channel?.program == null ? '' : '&program=' + encodeURIComponent(channel.program);
|
||||
let probeInfo = null;
|
||||
this.updateTranscodeStatus('hidden');
|
||||
|
||||
@ -661,8 +660,7 @@ class VideoPlayer {
|
||||
if (this.settings.autoTranscode) {
|
||||
console.log('[Player] Auto Transcode enabled. Probing stream...');
|
||||
try {
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}${programQuery}`);
|
||||
const info = await probeRes.json();
|
||||
const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(streamUrl, { program: channel?.program });
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
probeInfo = info;
|
||||
console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
|
||||
@ -724,6 +722,7 @@ class VideoPlayer {
|
||||
// Compatible - fall through to normal HLS.js path
|
||||
console.log('[Player] Auto: Using HLS.js (compatible)');
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.warn('[Player] Probe failed, using normal playback:', err.message);
|
||||
// Continue with normal playback on probe failure
|
||||
}
|
||||
@ -753,11 +752,10 @@ class VideoPlayer {
|
||||
// Probe to get video codec for HEVC tag handling
|
||||
let videoCodec = 'unknown';
|
||||
try {
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}${programQuery}`);
|
||||
const info = await probeRes.json();
|
||||
const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(streamUrl, { program: channel?.program });
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
videoCodec = info.video;
|
||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||
} catch (e) { if (e.name === 'AbortError') return; console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { program: channel?.program || null, videoMode: 'copy', videoCodec });
|
||||
@ -805,9 +803,9 @@ class VideoPlayer {
|
||||
this.updateTranscodeStatus('remuxing', this.settings.forceRemux ? 'Remux (Force)' : 'Remux (Program)');
|
||||
if (!probeInfo) {
|
||||
try {
|
||||
const response = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}${programQuery}`);
|
||||
probeInfo = await response.json();
|
||||
probeInfo = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(streamUrl, { program: channel?.program });
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
console.warn('[Player] Remux probe failed:', error.message);
|
||||
}
|
||||
}
|
||||
@ -1115,6 +1113,7 @@ class VideoPlayer {
|
||||
* Stop playback
|
||||
*/
|
||||
stop() {
|
||||
this.playbackProbe?.cancel();
|
||||
this.zapController?.abort();
|
||||
this.zapController = null;
|
||||
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
|
||||
|
||||
@ -421,7 +421,6 @@ class WatchPage {
|
||||
this.setSourceDuration(VodDuration.firstValid(VodDuration.fromContent(this.content || {}), knownDuration));
|
||||
const playbackGeneration = this.playbackGeneration;
|
||||
const program = this.content?.program ?? this.content?.data?.program ?? null;
|
||||
const programQuery = program == null ? '' : '&program=' + encodeURIComponent(program);
|
||||
let probeInfo = null;
|
||||
|
||||
// Show loading spinner
|
||||
@ -446,8 +445,7 @@ class WatchPage {
|
||||
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
|
||||
try {
|
||||
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}${programQuery}`);
|
||||
const info = await probeRes.json();
|
||||
const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' });
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
probeInfo = info;
|
||||
this.setSourceDuration(info.duration);
|
||||
@ -497,6 +495,7 @@ class WatchPage {
|
||||
// Compatible - fall through to normal playback
|
||||
console.log('[WatchPage] Auto: Using normal playback (compatible)');
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.warn('[WatchPage] Probe failed, using normal playback:', err.message);
|
||||
// Continue with normal playback on probe failure
|
||||
}
|
||||
@ -529,11 +528,10 @@ class WatchPage {
|
||||
let videoCodec = 'unknown';
|
||||
try {
|
||||
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}${programQuery}`);
|
||||
const info = await probeRes.json();
|
||||
const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' });
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
videoCodec = info.video;
|
||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||
} catch (e) { if (e.name === 'AbortError') return; console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(url, {
|
||||
@ -555,9 +553,9 @@ class WatchPage {
|
||||
if (!probeInfo) {
|
||||
try {
|
||||
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
|
||||
const response = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}${programQuery}`);
|
||||
probeInfo = await response.json();
|
||||
probeInfo = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' });
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
console.warn('[WatchPage] Remux probe failed:', error.message);
|
||||
}
|
||||
}
|
||||
@ -658,6 +656,7 @@ class WatchPage {
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.playbackProbe?.cancel();
|
||||
this.playbackGeneration = (this.playbackGeneration || 0) + 1;
|
||||
this.audioRecovery?.dispose();
|
||||
this.audioRecovery = null;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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