Files
XC_VM/tests/Unit/StreamProcessSubtitleTest.php
T
obscuremindandClaude Opus 5 2b61e48ef5 fix(streaming): feed delayed, LLOD and loopback channels to the daemon
Client delivery is daemon-only since ADR 0003 Phase E, but several
producers never fed the daemon, or fed it in a way no player could use:

- Delayed channels (delay_minutes > 0) were unwatchable: the encoder's tee
  is skipped for them (its output is the undelayed stream), the supervisor
  refuses them, and DelayCommand never registered an ingest, so every
  viewer got not-on-air. DelayCommand now pushes each delayed segment into
  the daemon as it publishes it (IngestFeeder), paced over the segment's
  duration, with a two-segment burst at start. Its loop polls every 50 ms
  instead of hashing the playlist every 1 ms.
- An ffmpeg loopback (php_loopback off, or no supervision) registered no
  ingest at all; startStream now tees loopback streams too.
- Encrypted HLS was undecodable for loopback and llod=2: the playlist
  declares AES-128 whenever encrypt_hls is on, but those producers
  registered without the key, so the daemon served plain segments. Every
  producer now passes the key; startLLOD/startLoopback write it before
  spawning the child that registers.
- LlodCommand/LoopbackCommand feed through IngestFeeder (no torn packets,
  reconnect after a daemon restart). LLOD also honours the stream's
  headers/cookie/proxy/default user agent and request_prebuffer, accepts
  TS sources by content when the Content-Type is not video/mp2t, kills a
  stale segmenter by process title (it read the MONITOR's pid), and writes
  its playlist atomically (the loopback relay too).
- LLOD ffmpeg: +nobuffer moves to the input where it has an effect;
  -tune zerolatency only for x264/x265 (NVENC rejects it and failed the
  start; it gets -zerolatency 1); an LLOD start uses the first source
  instead of falling through to the last.
- Remote VOD subtitles URL-encoded the shell-quoted path.
- cron:streams: the restreamer "attached" subqueries lacked GROUP BY, so
  on-demand parents could be stopped under a child LB; a stopped on-demand
  stream went on to spawn thumbnail/archive workers; the daemon re-feed
  restart now skips self-feeding producers (PHP relays, delay) and its
  throttle stamp moved out of STREAMS_PATH, where the restart's
  `rm -f <id>_*` deleted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:19:02 +01:00

122 lines
4.1 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Domain\Stream\StreamProcess;
/**
* Unit tests for StreamProcess::buildSubtitleImport() — the VOD subtitle
* import/metadata builder extracted from startMovie.
*
* Guards the fix for the nested-loop $i reuse that imported only the first
* subtitle while emitting -map metadata for every file.
*/
final class StreamProcessSubtitleTest extends TestCase {
public static function setUpBeforeClass(): void {
if (!defined('SERVER_ID')) {
define('SERVER_ID', 1);
}
}
/**
* Invoke the private static buildSubtitleImport() via reflection.
*
* @return array{0:string,1:string} [$import, $metadata]
*/
private function build(string $json, array $servers = []): array {
$m = new ReflectionMethod(StreamProcess::class, 'buildSubtitleImport');
$m->setAccessible(true);
return $m->invoke(null, $json, $servers);
}
public function testNoSubtitlesReturnsEmptyStrings(): void {
[$import, $meta] = $this->build('');
$this->assertSame('', $import);
$this->assertSame('', $meta);
[$import, $meta] = $this->build('{"files":[]}');
$this->assertSame('', $import);
$this->assertSame('', $meta);
}
public function testSingleLocalSubtitleImportedAndMapped(): void {
$json = json_encode([
'location' => SERVER_ID,
'files' => ['/subs/en.srt'],
'charset' => ['UTF-8'],
'names' => ['English'],
]);
[$import, $meta] = $this->build($json);
$this->assertSame(1, substr_count($import, '-sub_charenc'));
$this->assertStringContainsString('/subs/en.srt', $import);
$this->assertSame(1, substr_count($meta, '-map '));
$this->assertStringContainsString('-map 1 ', $meta);
$this->assertStringContainsString('title=English', $meta);
}
/**
* The bug fix: every subtitle must be imported AND mapped. The old nested
* loop imported only the first file while mapping all of them, so this
* previously failed (import count was 1, not 3).
*/
public function testAllSubtitlesAreImportedAndMapped(): void {
$json = json_encode([
'location' => SERVER_ID,
'files' => ['/subs/en.srt', '/subs/fr.srt', '/subs/de.srt'],
'charset' => ['UTF-8', 'UTF-8', 'UTF-8'],
'names' => ['English', 'French', 'German'],
]);
[$import, $meta] = $this->build($json);
// All three imported (the old nested-loop code produced only one).
$this->assertSame(3, substr_count($import, '-sub_charenc'), 'all subtitles imported');
$this->assertStringContainsString('/subs/en.srt', $import);
$this->assertStringContainsString('/subs/fr.srt', $import);
$this->assertStringContainsString('/subs/de.srt', $import);
// Each mapped to its own input index 1..N.
$this->assertSame(3, substr_count($meta, '-map '));
$this->assertStringContainsString('-map 1 ', $meta);
$this->assertStringContainsString('-map 2 ', $meta);
$this->assertStringContainsString('-map 3 ', $meta);
}
public function testRemoteSubtitleUsesServerApiUrl(): void {
$servers = [2 => ['api_url' => 'http://node2/api?key=abc']];
$json = json_encode([
'location' => 2,
'files' => ['remote.srt'],
'charset' => ['UTF-8'],
'names' => ['Remote'],
]);
[$import] = $this->build($json, $servers);
$this->assertStringContainsString('http://node2/api?key=abc', $import);
$this->assertStringContainsString('action=getFile', $import);
$this->assertStringContainsString('filename=', $import);
}
/**
* The remote filename is URL-encoded from the raw path — not from the shell-
* quoted one, which put encoded quotes (%27) into the name the remote server
* looked up.
*/
public function testRemoteSubtitleFilenameCarriesNoShellQuotes(): void {
if (PHP_OS_FAMILY === 'Windows') {
$this->markTestSkipped('escapeshellarg() rewrites % on Windows; the panel runs on Linux');
}
$servers = [2 => ['api_url' => 'http://node2/api?key=abc']];
$json = json_encode([
'location' => 2,
'files' => ['/subs/My Movie.srt'],
'charset' => ['UTF-8'],
'names' => ['Remote'],
]);
[$import] = $this->build($json, $servers);
$this->assertStringContainsString('filename=' . urlencode('/subs/My Movie.srt'), $import);
$this->assertStringNotContainsString('%27', $import);
}
}