Files
XC_VM/tests/Unit/FanoutClientTest.php
T
Divarion_D 070d89b3c4 feat(streaming): P2/A2 — tee non-proxy live ffmpeg into the xc_fanout daemon
Bring non-proxy live streams into the daemon (ADR 0003, Phase A). Before
launching a standard live stream's ffmpeg, StreamProcess registers a daemon
ingest (FanoutClient::registerIngest → PUT /ingest/<id>, returns the socket)
and, when the daemon accepted it, builds the HLS output through the `tee`
muxer so one ffmpeg feeds BOTH the existing on-disk HLS and the daemon:

  -map … -f tee "[f=hls:…]<id>_.m3u8|[f=mpegts:onfail=ignore]unix:<ingest.sock>"

- onfail=ignore keeps the on-disk HLS alive if the daemon output drops
  (verified: ffmpeg "continuing with 1/2 slaves"), so a daemon crash never
  takes the stream down. A plain second output would abort ffmpeg entirely.
- Daemon unreachable ⇒ registerIngest returns null ⇒ the original on-disk-only
  HLS output runs, unchanged (the reachability rollback). Loopback/delay
  keep the legacy output.
- Two tee-muxer gotchas handled: tee needs an EXPLICIT -map (plain outputs use
  automatic selection; tee errors "does not contain any stream") — reuse the
  stream's map or -map 0 -copy_unknown; and `individual_header_trailer` is a
  segment-muxer option the hls muxer ignores in flag form but rejects fatally
  inside a tee slave, so it is omitted.
- stopStream unregisters the daemon ingest; FanoutClient::call() now checks the
  socket exists so unregister is a no-op when the daemon isn't deployed.

Box-validated (real panel + ffmpeg, stream 569): one ffmpeg feeds on-disk HLS
(5 segs, m3u8) AND the daemon (/live TS + /hls in-RAM), no errors; panel
playback via the legacy path unaffected (1.69 MB valid TS). PHPStan clean,
441 unit tests pass.

Refs ADR 0003 (Phase A).
2026-08-16 18:16:45 +03:00

89 lines
3.2 KiB
PHP

<?php
use XcVm\Streaming\Fanout\FanoutClient;
use PHPUnit\Framework\TestCase;
/**
* @covers \XcVm\Streaming\Fanout\FanoutClient
*
* Exercises the pure source-config builder that feeds the xc_fanout daemon's
* control API (ADR 0002, P2). buildSource() mirrors ProxyCommand's argument
* extraction; the output shape must match the daemon's streamConfig JSON
* (urls/ua/proxy/cookie/ffmpeg).
*/
final class FanoutClientTest extends TestCase {
public function testBuildSourceDecodesStreamSourceArray() {
$row = ['stream_source' => json_encode(['http://a/1.ts', 'http://b/2.m3u8'])];
$src = FanoutClient::buildSource($row, []);
$this->assertSame(['http://a/1.ts', 'http://b/2.m3u8'], $src['urls']);
$this->assertArrayHasKey('ua', $src);
$this->assertArrayHasKey('proxy', $src);
$this->assertArrayHasKey('cookie', $src);
$this->assertArrayHasKey('ffmpeg', $src);
}
public function testBuildSourceTrimsAndDropsEmptyUrls() {
$row = ['stream_source' => json_encode([' http://a/1.ts ', '', ' '])];
$src = FanoutClient::buildSource($row, []);
$this->assertSame(['http://a/1.ts'], $src['urls']);
}
public function testBuildSourceDefaultsUserAgentWhenArgumentMissing() {
$row = ['stream_source' => json_encode(['http://a/1.ts'])];
$src = FanoutClient::buildSource($row, []);
$this->assertSame('Mozilla/5.0', $src['ua']);
$this->assertSame('', $src['proxy']);
$this->assertSame('', $src['cookie']);
}
public function testBuildSourceFallsBackToArgumentDefaultUserAgent() {
$row = ['stream_source' => json_encode(['http://a/1.ts'])];
$args = ['user_agent' => ['value' => '', 'argument_default_value' => 'VLC/3.0']];
$src = FanoutClient::buildSource($row, $args);
$this->assertSame('VLC/3.0', $src['ua']);
}
public function testBuildSourcePrefersExplicitUserAgentValue() {
$row = ['stream_source' => json_encode(['http://a/1.ts'])];
$args = ['user_agent' => ['value' => 'MyPlayer/1.0', 'argument_default_value' => 'VLC/3.0']];
$src = FanoutClient::buildSource($row, $args);
$this->assertSame('MyPlayer/1.0', $src['ua']);
}
public function testBuildSourceExtractsProxyAndCookie() {
$row = ['stream_source' => json_encode(['http://a/1.ts'])];
$args = [
'proxy' => ['value' => '1.2.3.4:8080'],
'cookie' => ['value' => 'sid=xyz'],
];
$src = FanoutClient::buildSource($row, $args);
$this->assertSame('1.2.3.4:8080', $src['proxy']);
$this->assertSame('sid=xyz', $src['cookie']);
}
public function testBuildSourceHandlesMissingOrInvalidStreamSource() {
$this->assertSame([], FanoutClient::buildSource([], [])['urls']);
$this->assertSame([], FanoutClient::buildSource(['stream_source' => ''], [])['urls']);
$this->assertSame([], FanoutClient::buildSource(['stream_source' => 'not-json'], [])['urls']);
}
public function testRegisterRejectsEmptyUrlsWithoutTouchingDaemon() {
// No daemon is running in tests; empty urls must short-circuit to false
// before any socket call is attempted.
$this->assertFalse(FanoutClient::register(42, ['urls' => []]));
}
public function testRegisterIngestReturnsNullWithoutDaemon() {
// FANOUT_CTL_SOCK is not defined in the unit env, so registerIngest must
// fail closed (null) — the caller then launches ffmpeg legacy-only.
$this->assertNull(FanoutClient::registerIngest(42));
}
}