Files
XC_VM/tests/Unit/HLSGeneratorLegacyTest.php
T
Claude 60b471d540 feat(streaming): serve live streams the pre-fanout way when fanout is off
With fanout switched off (FanoutMode::legacyDelivery(), which is also true
when the licence denies fanout), live delivery goes back to the paths that
ADR 0003 Phase E removed. Each is restored from git history:

- HLS (E3): live.php serves HLSGenerator::generateHLS() over the on-disk
  playlist. segment.php serves on-disk LIVE segments again through
  X-Accel /xc_hls/, AES-128-encrypting on first read, and burns a pending
  send-message signal with SignalSender. generateHLS now mints tokens with
  Encryption::mintToken (so secure_stream_tokens applies), rewrites one
  segment line at a time (a str_replace of 1_1.ts also hit 11_1.ts), and
  tokenizes an fMP4 init map.
- TS (E2): live.php chase-reads the on-disk segments again (SegmentReader
  and its test restored). $rSegmentSettings, which the old loop read but
  never defined, is now set there. A signalled segment is no longer sent
  twice.
- Proxy streams (E1): StreamProcess::startProxy() starts ProxyCommand
  (XC_VMProxy[<id>]), and live.php relays its datagrams from the viewer's
  unix socket. Fixed while restoring:
  - the read loop ended after its first read (a stray break);
  - it spun on an idle source;
  - it never exited when the last viewer left;
  - it passed the proxy argument's whole row to ffmpeg;
  - it ran ffmpeg with no input when no source opened.
  Runtime state goes through StreamStateWriter.

With fanout on, nothing changes: a daemon that is merely down still gives
not-on-air until the keepalive restarts it, and segment.php still 404s
non-daemon live segments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
2026-09-25 10:07:47 +00:00

93 lines
4.0 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Util\Encryption;
use XcVm\Streaming\Delivery\HLSGenerator;
/**
* HLSGenerator::generateHLS — the pre-fanout client playlist, built from the
* on-disk playlist while fanout is switched off.
*/
class HLSGeneratorLegacyTest extends TestCase {
private string $rDir;
private const PASS = 'test-live-pass';
protected function setUp(): void {
if (!defined('SERVER_ID')) {
define('SERVER_ID', 1);
}
if (!defined('STREAMS_PATH')) {
define('STREAMS_PATH', sys_get_temp_dir() . '/xcvm-hls-legacy-test/');
}
if (!is_dir(STREAMS_PATH)) {
mkdir(STREAMS_PATH, 0777, true);
}
$this->rDir = STREAMS_PATH;
}
protected function tearDown(): void {
@unlink($this->rDir . '9901_.m3u8');
@unlink($this->rDir . '9901_.iv');
}
private function settings(array $rOver = []): array {
return array_merge(['live_streaming_pass' => self::PASS, 'encrypt_hls' => 0, 'allow_cdn_access' => 0, 'secure_stream_tokens' => 0], $rOver);
}
private function playlist(string $rBody): string {
$rPath = $this->rDir . '9901_.m3u8';
file_put_contents($rPath, $rBody);
return $rPath;
}
private function build(array $rSettings, string $rPath) {
return HLSGenerator::generateHLS($rSettings, $rPath, 'user', 'pass', 9901, 'uuid-1', '10.0.0.1', null, '', 'h264', 0, 1, null);
}
public function testMissingPlaylistOrNoSegmentsIsFalse(): void {
$this->assertFalse($this->build($this->settings(), $this->rDir . 'nope_.m3u8'));
$this->assertFalse($this->build($this->settings(), $this->playlist("#EXTM3U\n#EXT-X-TARGETDURATION:10\n")));
}
public function testEverySegmentBecomesATokenThatSegmentPhpReads(): void {
$rOut = $this->build($this->settings(), $this->playlist("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-MEDIA-SEQUENCE:10\n#EXTINF:10.0,\n9901_10.ts\n#EXTINF:10.0,\n9901_11.ts\n"));
$this->assertIsString($rOut);
$this->assertStringContainsString('#EXT-X-MEDIA-SEQUENCE:10', $rOut);
$this->assertStringNotContainsString('9901_10.ts', $rOut);
preg_match_all('#^/hls/(\S+)$#m', $rOut, $rM);
$this->assertCount(2, $rM[1]);
// The payload segment.php splits on '/': user/pass/ip/stream/segment/uuid/server/codec/on_demand.
$rParts = explode('/', (string) Encryption::readToken($rM[1][1], self::PASS, OPENSSL_EXTRA, true));
$this->assertSame(['user', 'pass', '10.0.0.1', '9901', '9901_11.ts', 'uuid-1', '1', 'h264', '0'], $rParts);
}
public function testOverlappingNamesAreRewrittenOneLineAtATime(): void {
// 9901_1.ts is a substring of 19901_1.ts-like names; each line must get its own token.
$rOut = $this->build($this->settings(), $this->playlist("#EXTM3U\n#EXTINF:2.0,\n9901_1.ts\n#EXTINF:2.0,\n9901_11.ts\n"));
preg_match_all('#^/hls/(\S+)$#m', $rOut, $rM);
$this->assertCount(2, $rM[1]);
$rSegs = array_map(fn($t) => explode('/', (string) Encryption::readToken($t, self::PASS, OPENSSL_EXTRA, true))[4], $rM[1]);
$this->assertSame(['9901_1.ts', '9901_11.ts'], $rSegs);
}
public function testCdnAccessKeepsTheNameAndAddsTheToken(): void {
$rOut = $this->build($this->settings(['allow_cdn_access' => 1]), $this->playlist("#EXTM3U\n#EXTINF:10.0,\n9901_5.ts\n"));
$this->assertMatchesRegularExpression('#^/hls/9901_5\.ts\?token=\S+$#m', $rOut);
}
public function testEncryptedHlsDeclaresTheKeyAfterTheHeader(): void {
file_put_contents($this->rDir . '9901_.iv', str_repeat("\x02", 16));
$rOut = $this->build($this->settings(['encrypt_hls' => 1]), $this->playlist("#EXTM3U\n#EXTINF:10.0,\n9901_5.ts\n"));
$rLines = explode("\n", $rOut);
$this->assertSame('#EXTM3U', $rLines[0]);
$this->assertStringStartsWith('#EXT-X-KEY:METHOD=AES-128,URI="/key/', $rLines[1]);
$this->assertStringEndsWith(',IV=0x' . str_repeat('02', 16), $rLines[1]);
}
public function testFmp4InitMapIsTokenized(): void {
$rOut = $this->build($this->settings(), $this->playlist("#EXTM3U\n#EXT-X-MAP:URI=\"9901_init.mp4\"\n#EXTINF:10.0,\n9901_5.m4s\n"));
$this->assertMatchesRegularExpression('#\#EXT-X-MAP:URI="/hls/[^"]+"#', $rOut);
$this->assertStringNotContainsString('9901_init.mp4', $rOut);
}
}