Mechanical output of `make rector-fix` over the class-based trees, reviewed
change by change. Most of it is empty()/=== [] normalisation, a chained === turned
into in_array(..., true) in FanoutConfig, and multi-line condition reformatting.
Three changes were checked more closely because they looked behaviour-affecting:
- Translator::isRtl() loses its (string) cast on $lang. Safe: $currentLang is a
non-nullable `private static string`, so the ??= above always leaves a string.
- TableController loses htmlValue() and jsArgument(). They are escaping helpers,
so the removal was verified rather than trusted: neither has a single reference
anywhere under src/, views included. Genuinely dead.
- StartupCommand and StreamsCronJob have three private helpers turned from static
into instance methods (LocallyCalledStaticMethodToNonStatic).
That last one broke 7 tests, which reached the helpers through Reflection and
invoked them statically. Fixed in the tests rather than by reverting the rule —
reverting would only lose the argument again on the next `make rector-fix`. The
Reflection helpers now bind an instance only when the method needs one, so they
survive the flip in either direction.
Also re-ran the two KNOWN BUG greps from build/rector.php (the rules that drop
parens around an assignment-in-condition and invert the guard): both clean.
Verified: 857 tests, make gates, make cs, CRAP gate.
Port of Rosmi720/XC_VM@0d39e9bd. A mode-dropping deploy could leave core
scripts non-executable and a php-fpm pool config missing, and an undefined HOST
constant broke the unique-header cookie domain:
- StartupCommand restores the executable bit on service/update/redis-server/
daemons.sh and regenerates any missing php-fpm pool config (1..4.conf) from
the template on boot.
- StreamAuthMiddleware derives the cookie domain through a HOST-guarded helper,
so an undefined/empty host or a bare IP yields no domain instead of throwing
or emitting a broken "." domain.
Adapted to this tree: the added boot blocks were split into covered helpers
(ensureExecutableScripts / ensurePhpFpmPoolConfigs / uniqueHeaderDomain) with
unit tests to stay within the complexity ceiling, the sudo chown now runs only
as root, and comments were translated to English.
Port of Rosmi720/XC_VM@2140b00f. A missing/corrupt MaxMind database or an
undefined HOST constant could fatal the stream request (white screen on live):
- GeoIPService::getIPInfo()/getISP() no longer construct the MaxMind reader
unguarded. A missing constant/binary or any reader error now yields false
instead of a fatal, via a shared readMmdb() helper (unit-tested).
- StreamRedirector::getStreamingURL() reads HOST through a defined() guard so
an undefined constant does not throw while resolving the broadcast domain.
Adapted to this tree: the added guards/try-catch were folded into readMmdb so
getIPInfo/getISP stay within the complexity ceiling.
The fanout debug field was free text (empty, "all", or a comma list of
category names typed from the tooltip), so a typo silently meant off.
Make it a dropdown of every value the daemon takes: Off, All categories,
or one category. FanoutConfig::DEBUG_CATEGORIES is now the single list
both the dropdown and debugCats() use; a combination saved before stays
selectable as it is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4P9p5BXijMoGXd31tN6Zp
FanoutConfig::desired maps a new fanout_debug setting to the daemon's debug_cats
config key: "" (off, the default), "all", or a comma-separated category list
kept to the daemon's known categories (unknown names dropped, so a typo turns
debug off rather than writing a file the daemon second-guesses). The daemon
applies it on its next config poll, so an operator can turn debug on for a
misbehaving node and off again without a restart that would drop every viewer.
The PHPUnit suite could not be run here — no PHP on this box has the
dom/mbstring/xml/xmlwriter extensions phpunit.phar needs — so the three tests
added to FanoutConfigTest are unrun; the debugCats() mapping was instead
verified directly by reflection (off/all/list/unknown all map as asserted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two control calls the daemon has always answered and the panel never made.
FanoutClient::memory() reads GET /memory, and status() now carries it — so it
lands in servers.watchdog_data through the watchdog that already writes that
array, and the admin server views read it back by key with no view change. The
daemon's RSS is dominated by the per-stream join rings, by design: one watched
channel holds its whole prebuffer. Until now the panel could see that a node
was running hot and nothing more, and "the rings are as big as the tuning says"
and "something else is growing" are different problems with different answers.
FanoutClient::unregisterAll() reads DELETE /streams, the bulk teardown for a
maintenance drain or a decommission, instead of scripting a DELETE per id. The
daemon logs each teardown, so it stays an auditable operator action.
Both answer null when the daemon is unreachable, which for the watchdog is an
ordinary state rather than an error, and status() skips the memory call when
the socket did not answer.
Verified by loading the class directly: with no control socket, status() keys
are running,socket,connections,memory with memory null, and both new calls
answer null. The PHPUnit suite could NOT be run here — no PHP on this box has
the dom/mbstring/xml/xmlwriter extensions phpunit.phar requires — so the two
tests added to FanoutClientTest are unrun; they assert exactly what the harness
above printed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AsyncFileOperations, DatabaseHandler and Logger are PSR-4 classes resolved by the
Composer autoloader, so the explicit require_once of each (in StreamingBootstrap
and StreamingRequestBootstrap) is dead weight on the hottest path. Remove them;
behaviour is unchanged.
StreamingRequestBootstrap otherwise keeps its own fail-closed guard flow (settings
from the file cache, PHP_ERRORS from cache, the 'exit' gate) inline: unlike the
web-API path it has no DB step in common with the kernel — the connection is made
inside LegacyInitializer::initStreaming via DatabaseFactory::connect — so there is
nothing to fold into the shared stages.
Verified in a php+MariaDB dev container: the three classes autoload without the
requires and StreamingRequestBootstrap::init('status') boots and reaches the DB.
Widen the Rector config to the remaining class-based trees (Streaming,
Public\Controllers, Ministra), keeping the templates, procedural
front-controllers (Public/stream, Public/admin, Ministra/portal.php) and the
streaming hot-path bootstraps out. Also document the recurring dropped-parens
bug + the review grep in the config header.
The resulting 107-file pass (94 Public\Controllers, 11 Streaming, 2 Ministra)
was reviewed against the suite + PHPStan + the usual scans:
- No dropped-parens bug this time (the assignment-in-condition pattern didn't
occur in these files).
- 6 locally-called private static helpers became instance methods
(behaviour-preserving; all called via $this->, no static call sites); one
unused private param dropped (FanoutConfig::desired $rSnapshot, call site
updated).
- De Morgan / ternary / dead-code simplifications; two `$x = getById()`
truthy-assignments folded into the condition (no comparison, safe).
PHPStan level 5 clean; suite 721 tests / 0 errors.
The cs-fix run in 758a9cab auto-inserted native parameter types from docblocks
(the SlevomatCodingStandard.TypeHints.ParameterTypeHint sniff is a fixer). Some
docblocks were inaccurate, so the inserted types were stricter than the values
the code actually receives — PHPStan (level 5) flagged the fallout. Fixes:
Over-narrowed types (would fatal at runtime, restored to real contract):
- DropboxClient GetFiles/Copy/Move: string -> string|object on the path params
(the methods handle a metadata object via is_object(), like DownloadFile/
Restore/GetCopyRef); GetMetadata $rev: null -> ?string.
- Bouquet addItems/removeItems $rIDs: array -> array|int|string (callers pass a
single scalar id; the is_array() guard wraps it).
- EpgService searchRecursive $rArray: array -> mixed (it recurses over every
value, scalars included; the is_array() guard skips non-arrays).
- Encryption generateUniqueCode $pass: string -> ?string (callers pass
SettingsManager::get('live_streaming_pass'), which can be null).
Correct types, dead guard removed (behaviour unchanged):
- Request::parseIncomingRecursively, StreamProcess::isLocallyMountedPath,
FfmpegPaths::binary, ArchiveCommand::isArchiveProcessForStream,
player_utility_functions getUserStreams/getUserSeries.
PHPStan: 15 -> 1 (the remaining one is a pre-existing ImageUtils logic bug,
unrelated to typing). Suite: 685 tests, 0 errors.
Mechanical, behaviour-preserving reformat produced by 'make cs-fix' under
the new build/phpcs.xml.dist ruleset: K&R braces, tab indentation, and the
other whitespace normalisations. No logic changes.
Stream-link tokens were AES-CBC with a fixed IV and no MAC. A modified token
decrypts to modified bytes, and a padding error answers differently from a bad
credential (auth.php: BAD_TOKEN vs everything after), so with enough requests
anyone holding a link could read its username and password, or write a token of
their own. Several consumers trust a token's contents as they stand: the live /
vod / timeshift JSON (user_info, channel_info), HLS segment and key tokens, the
web player's proxy URL (fetched server-side) and the MAG portal's verify token
(passed to igbinary_unserialize).
Encryption::seal()/open() add AES-256-GCM with a random nonce, as
base64url(nonce ‖ ciphertext ‖ tag) — the same URL-safe alphabet, so no nginx
route or pattern changes. Every stream-link token is now made with
mintToken() and read with readToken(); StreamTokenCallSitesTest keeps new code
from calling the legacy encrypt()/decrypt() for one. Deterministic encryption
of stored data (HMAC keys looked up by ciphertext, image cache names) stays as
it was.
The new setting secure_stream_tokens (Settings → Tamper-proof Stream Tokens):
- on: tokens are sealed, and the legacy format is refused wherever a token's
contents are trusted. /play/ playlist and portal links, RTMP tokens and
probe's /play/ links still read the old format — they carry credentials that
are looked up again, and saved playlists hold them — and every token auth.php
cannot read now counts against the address (BruteforceGuard), which stops
reading an old one through the error responses.
- off: legacy tokens are minted and every format is read.
Servers on an older version cannot read sealed tokens, so migration 021 turns it
off on a panel that has other servers (on for a single server, and for new
installs); turn it on once every server is updated.
key.php now also refuses a token that does not read, instead of serving the key
of stream 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbYsGKhirq9eRK8e6wsCHR
The panel wrote prebuffer_max_sec = max(40, client_prebuffer,
restreamer_prebuffer, hls_window x seg_time). The ring is the daemon's
memory, and the flat 40 kept every watched channel at 40 s however far the
operator lowered Client Prebuffer or the HLS window: no panel setting could
shrink it, and a hand edit of config.json is overwritten by the next sync.
The ring now covers the HLS window and every prebuffer a viewer can ask for
(client, restreamer, the daemon default), each with one segment of headroom
on top — a join asking for as much as the ring holds starts in the block the
next keyframe prunes, and a viewer slower than one GOP there is dropped. It is
never under two segments, and still capped at the daemon's 120. At the
defaults (30 s prebuffer, 6 x 6 s HLS) that is 36 s instead of 40; with a
10 s prebuffer and a 3-segment window it is 18 s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbYsGKhirq9eRK8e6wsCHR
- An on-demand start waited a fixed ~6 s for the producer's pid, although
a non-LLOD start probes the source first (up to a 10 s analyze window);
the wait is now on_demand_wait_time.
- The pid/monitor files of a dead run (e.g. a monitor that gave up under
on_demand_failure_exit) are cleared before a new monitor starts, so the
waits cannot read them back as the new ones.
- disallow_2nd_ip_con accepted the NEWEST connection's IP on the MySQL
path (the Redis path already takes the first).
- live.php passes the connection uuid to the limiter and the stream id to
OffAirHandler (HLS sequence), and no longer warns on ob_end_clean().
- AsyncFileOperations: the "100 ms" read cache compared seconds against
100000 (27 h); waits clear that cache when the file appears; the inotify
path is bounded by time with a non-blocking read (it blocked forever
when the file never appeared, and counted events instead of time).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Timeshift TS seeking returned the wrong bytes: the start file was
estimated from the average file size, files before it were never skipped
(an empty `if` where a `continue` belonged) and the in-file offset came
out negative, so every catch-up seek streamed from the archive's first
byte; the range end was ignored too. The served range is now mapped
exactly onto the minute files (first file from its .offset).
- The timeshift throttle never reset its chunk counter (vod.php's copy
did), so past vod_limit_perc every chunk paused a whole second.
- A shared HttpRange parser (RFC 7233 single ranges) replaces the inline
copies: suffix ranges (bytes=-N) died on PHP 8 arithmetic, an
unsatisfiable range answered with the resource's range instead of
`bytes */size`, and Accept-Ranges said "0-<len>" instead of "bytes".
VOD never reads past a bounded range's end.
- Direct-proxy VOD reads the source's headers with cURL: get_headers()
goes through the https stream wrapper, which does not work under
PHP-FPM here, and returned Content-Length as an array after a redirect.
The upstream is asked for exactly the requested range, and curl's
verbose output no longer goes to the FPM log on every request.
- The limiter spares the requesting connection by uuid (VOD/timeshift).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HlsSequence re-applied the off-air wall-clock floor (time()/10) on every
publish. The daemon's segment length follows seg_time (1-30 s) and keyframe
cuts stretch it further, so for segments over 10 s the daemon counter fell
behind the floor and each catch-up shifted every listed segment by one —
players replayed or skipped a segment several times a minute.
The floor now applies only where a player can have seen the off-air loop:
the first publish, a daemon counter that went backwards, a publish gap
longer than max(30 s, 3 target durations), or an explicit markOffAir(),
which OffAirHandler calls when it serves the off-air playlist for a known
stream. The target duration is read from the daemon playlist. The state
moved from STREAMS_PATH, where a stream restart's `rm -f <id>_*` erased it
(letting the next publish step back under what a connected player had
seen), to tmp/signals.
Also removes the dead HLSGenerator::generateHLS() (no callers since
Phase E) and fixes tokenizeDaemonPlaylist's stale "unencrypted only" doc.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A daemon-served TS viewer's row is written with pid 0 — the PHP worker that
admitted it returns at the X-Accel hand-off. closeConnection() only killed
a pid above 0 and then deleted the row, so a limit eviction or an admin
kick left the viewer streaming from the daemon, untracked: a line limited
to one connection could hold any number of TS streams.
- FanoutClient::dropConnection() calls the daemon's new
DELETE /connections/<uuid> (XC_VM_Fanout, feature "drop_connection").
- ConnectionTracker::dropDaemonViewer() drops a pid-0 viewer directly, or
sends a drop_con signal to the viewer's node (DB and Redis signal paths,
handled by SignalsCommand). Both closeConnection() implementations use it.
- The limiter spares the requesting connection by uuid: every daemon row
shares pid 0, so the old "not my pid" check let a new viewer evict
itself. HMAC identities in Redis mode are looked up under their
"<hmac>_<identifier>" key (a null line id hit an int-typed parameter).
- A kicked local HLS viewer loses its segment marker at once, and in Redis
mode a closed HLS connection is no longer silently reopened by the next
playlist request (the MySQL path already required hls_end = 0). A
re-auth that reuses the player's deterministic HLS uuid leaves ENDED /
replaces the closed row, so the reaper cannot delete the new connection.
- fanout_sync also reconciles the other way: a daemon viewer whose row is
gone past a 20 s grace is dropped. It is skipped when the rows could not
be read, so a Redis/DB blip cannot disconnect everyone.
- Redis signal keys include the payload, so pid-less signals do not
overwrite each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since ADR 0003 Phase E the xc_fanout daemon is the only client delivery
path, so for the PHP producers (the LLOD segmenter, the loopback relay and,
next, the delay worker) the feed into its ingest socket IS the channel's
delivery. They wrote it with a bare non-blocking fwrite(): a short write
silently dropped the rest of the buffer, tearing TS packets, and after one
failure the feed stopped for the life of the process.
IngestFeeder keeps what the socket did not take and sends it first next
time (capped, shedding the oldest whole packets), discards a half-sent
packet's tail on reconnect so the new connection starts aligned,
re-registers and redials after a daemon restart with a backoff, and
carries the stream's HLS key/iv for encrypted HLS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things an operator could not see, all in the file they already open:
- the command handed to the supervisor is recorded beside the stream's files
the way the self-launched path records its ffmpeg line — <id>_.fanout for the
native remuxer, <id>_.ffmpeg for ffmpeg (in auto, both: the second is the
fallback). A supervised stream used to leave no record at all.
- when the native backend is on and a stream runs ffmpeg anyway, the reason is
appended to <id>.errors ("[panel] ffmpeg runs this stream: Generate PTS is
on"). isNativeEligible() becomes nativeRefusal(), returning that sentence
instead of a bare false, because the answer is always one of these settings.
- the panel only composes `xc_fanout remux` when the node's daemon advertises
it (features in GET /monitors/state, FanoutClient::supportsRemux). An older
binary does not reject that command, it misparses it — "remux" reads as a
positional argument, the process tries to become a second daemon on sockets
the running one holds, and the stream never starts. On a node whose panel was
updated first, streams now keep running ffmpeg and say so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K48c64npichw9ZCZDU16ja
With fanout_supervise on, StreamProcess::startMonitor() no longer spawns a
PHP watchdog. It builds the stream's commands and hands them to the
daemon's supervisor (PUT /monitor/<id>), which starts, watches and
restarts them: failover, priority backup, forced source, stalled output,
audio loss, frame-rate drop and scheduled restart. PHP still builds every
command and makes every database write.
A copy-only live stream's command is the daemon's native remuxer,
`xc_fanout remux`, composed by the new buildNativeLive() exactly as
buildLive() composes an ffmpeg line. It reads the source natively and
writes the same on-disk HLS and daemon feed as the ffmpeg -f tee output,
with no ffmpeg process. fanout_source_backend decides: auto = remuxer with
the ffmpeg command as fallback_cmd (taken when the remuxer exits 3,
"cannot serve this source": fMP4 or encrypted HLS, rtmp, no keyframes),
native = remuxer only, ffmpeg = ffmpeg only. Eligibility is explicit
(isNativeEligible / isNativeSource): no transcode, custom ffmpeg, custom
map, RTMP output, external push, timestamp repair, read-native or forced
input codec; http(s)/udp/rtp sources only.
The rest of the panel learns who owns the producer:
- superviseStream() asks the daemon first and touches nothing unless it
is accepting; without a restart it adopts a running encoder, so
cron:streams moves PHP-monitored streams over with no blip. A producer
the daemon cannot adopt (PHP LLOD, PHP loopback) is replaced, never left
beside the new one. The row is marked watched before the hand-over, so
the reconcile cannot release a stream mid-start.
- reconcileSupervised() copies the daemon's state (status, pid, source,
codecs, resolution, measured bitrate) into streams_servers: every
cron:streams pass and every 5 s from the signals daemon. Supervised
streams whose row is gone or stopped are released.
- stopStream() and the on-demand reaper release before killing anything;
killing the producer first is what the supervisor restarts.
- isWatched() replaces bare isMonitorAlive() checks in live.php,
admin/live.php, rtmp.php and cron:streams: a supervised stream's
monitor_pid is the daemon's. MonitorCommand stands down for supervised
streams; startMonitor() releases one before falling back to PHP, so
turning supervision off does bring streams back on their next restart.
- force_stream goes through the daemon for a supervised stream (the .force
file is only read by the PHP monitor).
- ProcessManager::isStreamRunning() recognises the remuxer, so the
archive, thumbnail and delay workers follow it like ffmpeg.
- A supervised loopback child tees into the daemon (the supervisor judges
a stream by the bytes it receives); legacy loopback is unchanged.
Delay streams, created channels and yt-dlp platform sources stay on the
PHP monitor. A daemon without /monitors/state (older than this) is never
handed a stream, so the panel is safe against an un-upgraded node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QL93N6dzGkmKoQgA4oh16
Whether live streams on each server are handed to the xc_fanout daemon's
encoder supervisor instead of getting a per-stream PHP watchdog
(console.php monitor). On by default: the supervisor does everything the
PHP monitor did, from one process per node instead of one per channel,
and the PHP monitor stays the fallback for a daemon that cannot be
reached.
Touches the places every fanout_* setting lives: migration 018 for
upgrades, database.sql for fresh installs (277/277 columns and values
still aligned), the FanoutConfig mapping that writes `supervise` into the
daemon's config.json (applied live by the daemon, no restart), the
settings UI, the checkbox whitelist in SettingsService, all seven language
files, and the unit test. The source-backend tooltip now says what the
backend means for the panel's own streams as well.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QL93N6dzGkmKoQgA4oh16
Reverts the five commits PR #1 brought in and the two test follow-ups made
after it merged:
a1d454fe Enhance assertions in FanoutConfigTest
19607a91 Update StreamProcessBuildLiveTest.php
b3714269 fix(fanout): stop, force and rogue-kill must account for the daemon
cea545fe feat(fanout): reconcile supervised streams back into the panel
8e24a242 feat(fanout): make encoder supervision a real, settable option
8244ece1 feat(fanout): health policy, source forcing and supervision reconcile
56c0619e feat(fanout): hand live encoders to the daemon to supervise
Only those. The upstream commits that reached main through the same merge
(the GeoIP refactor, release 2.5.0, the update guard, the mass-page fix)
stay: reverting the merge commit itself would have taken them out too.
The panel side of supervision is re-done in the commits that follow,
together with the xc_fanout native remuxer (xc_fanout 0.13.0): every live
stream handed to the daemon's monitor, copy-only streams run on
`xc_fanout remux` instead of ffmpeg, the ffmpeg command as an explicit
fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QL93N6dzGkmKoQgA4oh16
Production-readiness pass on the panel side of daemon encoder supervision.
Until now the feature was gated on a setting that did not exist as a
column, so it could only be enabled by hand-editing the database and the
daemon never learned about it at all.
* Migration 018 adds `fanout_supervise`, off for every existing install.
* Admin -> Settings gains the toggle, alongside the other fanout tuning.
* SettingsService saves it (it is a checkbox, so it has to be in the
boolean list or it can never be turned back off).
* FanoutConfig writes it into the daemon's config file as `supervise`,
which is how the node learns it may supervise at all. Both halves must
be on for anything to change, and either one off is a full rollback.
* StreamProcess reads `fanout_supervise` rather than the placeholder
name, matching the panel's `fanout_*` convention.
Also: the recorded command file now says WHO ran the stream. `_.fanout`
when the daemon owns the process (the bare command it was handed) and
`_.ffmpeg` when this node ran it itself (with the redirect-and-background
tail). Two names rather than one because the first question in any
incident is which path the stream took, and a single filename cannot
answer it; the stale one is removed so a stream that switched paths does
not leave a lie behind.
FanoutConfigTest covers the new key both ways, including that a settings
array predating it reads as off -- that absence is the upgrade path for
every existing install.
Verified: php -l clean on every changed file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the panel side of moving the per-stream watchdog into the
daemon (XC_VM_Fanout M5).
* daemonHealthPolicy() translates this stream's watchdog settings --
seg_time, audio_restart_loss, fps_restart/fps_threshold/fps_delay and
auto_restart -- into the daemon's health policy. These are the same
conditions MonitorCommand.php checked in its inner loop; the daemon now
judges them against the bytes it is already fanning out instead of by
hashing a playlist, ffprobing a segment and reading a progress file.
Each is omitted when the panel has it switched off.
* supervise() now sends adopt_match: the stream's own HLS path, which
appears in every live command buildLive produces and in no other
process. It is how the daemon recognises an encoder of ours that
outlived it, so a daemon restart resumes watching the running ffmpeg
rather than starting a second one beside it. Sending a token the panel
knows, rather than letting the daemon guess, is what makes that safe
against pid reuse.
* FanoutClient::supervisedIDs() reports what the daemon currently holds,
so the panel can notice a restarted daemon and hand its streams back.
Null (not an empty array) when unreachable, so a dead socket is not
read as every stream needing re-registration.
* FanoutClient::forceSource() replaces writing a <id>.force signal file.
Still gated behind the `daemon_supervise` setting, absent on every
existing install.
Verified: php -l clean on the changed files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Enables the daemon-side supervisor (XC_VM_Fanout M1, its ADR 0002): the
daemon starts, watches and restarts a stream's ffmpeg instead of a
resident PHP watchdog per channel doing it.
The daemon cannot read this panel's configuration -- database credentials
live inside the XC_VM C extension and are never handed out, not even to
PHP (Core/Config/ConfigReader.php) -- so the split is process ownership,
not config ownership. buildLive keeps composing the command here from the
database; the daemon only ever runs the command it is handed.
* buildLive no longer appends its own redirect-and-background tail. A
backgrounded command leaves the daemon nothing to supervise: it has to
be the process's parent to reap it and to signal it. The tail moves to
liveRedirectTail(), which the legacy path appends itself, so that path
is byte-for-byte what it was.
* startStream hands the bare command to the daemon when this node
supervises there, passing the panel's own restart policy
(stop_failures, stream_fail_sleep, on_demand, on_demand_failure_exit).
The daemon writes the same <id>_.pid file, so stopStream,
isStreamRunning and everything else downstream keep working untouched.
* FanoutClient gains supervise / releaseSupervision / monitorState /
isSupervised over the existing control socket.
* MonitorCommand stands down when the daemon holds the stream. Two
watchdogs on one ffmpeg fight -- each reads the other's kill as a
failure and restarts -- so the channel would flap forever. The check
asks the daemon rather than assuming, and an unreachable daemon answers
false, so this watchdog keeps working whenever the daemon is not
actually holding the process.
Off by default: daemonSupervises() reads the `daemon_supervise` setting,
absent on every existing install, so behaviour is unchanged until an
operator sets it. No migration, and clearing it is the rollback. Delay
streams are excluded for now (they write their own playlist).
Verified: php -l clean on the changed files and across all of src/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xc_fanout 0.12.0 can convert a non-mp2t source to MPEG-TS in-process instead of
spawning an ffmpeg child per stream. For the common IPTV case — HLS whose
segments are already MPEG-TS — that child was doing little more than
concatenating bytes the daemon can concatenate itself, at ~27 MB RSS apiece,
plus a process spawn and a probe window on every on-demand join and reconnect.
The daemon reads which path to take from `source_backend` in its config.json;
this adds the panel setting that writes it.
auto (default) — convert natively where the daemon's reader can, ffmpeg for
everything it declines
ffmpeg — always spawn ffmpeg; the pre-0.12 behaviour and the
kill-switch if a native pull ever misbehaves
native — native only, no fallback. Diagnostic: it answers "what is
actually eligible on this node", and a declined source there
is a dead channel rather than a slightly more expensive one.
auto is the default because the fallback makes it strictly safer than
ffmpeg-always: anything the native reader will not take (fMP4/CMAF HLS, RTMP,
SRT, RTSP, AES-128 encrypted sources, anything it cannot positively identify)
runs exactly the pipeline it ran before.
A `<select>`, not a numeric input, so it is deliberately kept out of the
numeric-coercion list in settings.php that would otherwise mangle the string.
An unrecognised value maps to auto rather than being written through — the
daemon clamps unknown values itself, but a config file we write should not carry
a backend that does not exist, and a typo must never take channels off air.
Touches the six places every fanout_* setting lives: migration 017 for upgrades,
database.sql for fresh installs (column, INSERT list and VALUES — 276/276 still
aligned), the FanoutConfig mapping, the settings UI, all seven language files,
and the unit test. SettingsService needs no change: its whitelist is only for
checkboxes, and verifyPostTable already maps a posted field to its column.
Verified against the real daemon end to end: the panel writing "ffmpeg" boots
xc_fanout with backend=ffmpeg, and changing it to "auto" is picked up live on
the next config poll. Read-modify-write still preserves daemon keys the panel
does not manage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGCQcWGMg1Wn8RdCxYoioN
The off-air placeholder (OffAirHandler) numbers its loop floor(time()/10)
(~1.7e8) while the xc_fanout daemon restarts its own HLS counter from 0 per
stream. On an on-demand cold start a player saw the off-air sequence (~1.7e8)
then the live one (~1) — a ~10^8 BACKWARD jump, which HLS forbids, stalling
playback during warm-up.
Re-anchor the live sequence (in HLSGenerator::tokenizeDaemonPlaylist) to the
same wall-clock base via a persisted per-stream offset so it only ever
advances: it stays >= the off-air line and >= the last live window, while
still incrementing by the daemon's per-segment delta. The off-air handler is
left untouched (it is stream-agnostic). Adds HlsSequence with a pure,
unit-tested reconcile() step plus HlsSequenceTest.
The panel only owned two derived fanout config keys (hls_target_sec ←
seg_time, prebuffer_max_sec derived); the daemon's other tuning knobs lived
only in its config.json and could not be set from the panel. Promote them to
real settings so admins control the whole config — the unconditional
fanout_sync (previous commit) then keeps config.json in step.
Adds 9 settings columns (fanout_hls_window, fanout_grace_sec,
fanout_write_timeout_sec, fanout_chunk_bytes, fanout_max_gop_bytes,
fanout_source_insecure, fanout_default_prebuffer_sec,
fanout_idle_buffer_grace_sec, fanout_idle_buffer_ratio) with defaults and
ranges mirroring the daemon schema (XC_VM_Fanout internal/config/config.go):
migration 014 + install SQL, inputs on the admin Settings page (numeric plus
one switchery checkbox), and the source_insecure toggle in the settings
boolean-normalization list.
FanoutConfig::desired() now writes all 11 keys, clamped to the daemon's own
ranges so a bad panel value can't push it into a pathological state;
hls_window comes from the setting (not the daemon snapshot) and feeds the
prebuffer_max_sec derivation. prebuffer_max_sec / hls_target_sec stay derived.
Adds FanoutConfigTest (mapping, clamps, derived ring, idempotency,
unknown-key preservation). Full suite green.
Discover the bundled FFmpeg builds from bin/ffmpeg_bin/<version>/ instead of a
hardcoded version list, and expose what each build can do.
- FfmpegBinaries (new): scans real version folders (skips *.backup.*), probes each
binary's hardware encoders (nvenc/qsv/vaapi/…), flags GPU capability, caches the
result to tmp/ffmpeg_capabilities.json keyed by a folder-mtime fingerprint. UI-only.
- FfmpegPaths::resolve() rewritten: builds paths dynamically from the version string
(is_file check, no shell — stays on the bootstrap hot path) and takes a separate GPU
version; the switch and the per-version constants are gone. Unknown CPU version falls
back to the legacy 4.0 build; an empty/invalid GPU version falls back to the CPU build.
- Wire the previously-dead ffmpeg_gpu column: LegacyInitializer passes it to resolve();
settings.php lists versions from the scanner and adds an ffmpeg_gpu <select> limited to
GPU-capable builds ("Same as CPU" = empty). No migration — the column already exists.
- Info tab: a per-build FFmpeg table (version, build banner, GPU badge, hw encoders).
- De-hardcode: drop the unused FFMPEG_BIN_71/80 + FFPROBE_BIN_71/80 constants; keep only
the 4.0 anchor (DTS override in StreamProcess + FfmpegPaths fallback). Llod/Loopback/
Record now use FfmpegPaths::cpu()/probe() (config-selected version) with the 4.0
constant as fallback, matching Scanner/Created/Thumbnail.
FanoutConfig does a read-modify-write of the xc_fanout daemon's config.json: read
the current snapshot, overlay only the panel-owned keys (hls_target_sec from
seg_time; prebuffer_max_sec sized to cover the largest per-viewer prebuffer and
the HLS window, floored at 40 and capped at 120), and write it back atomically.
Keys the daemon owns are preserved untouched, so a key it later adds is never
clobbered. No-op when the daemon tree is absent on the node.
Wired into SettingsService::edit() after a successful settings UPDATE; never
throws, so a config write can't break a settings save.
Harden the hot paths that dominated the panel error log with defensive
null/undefined-key guards; no behaviour change on the happy path:
- ConnectionTracker::removeFromQueue — skip when the queue signal file is
absent instead of file_get_contents() on a missing path (219 warnings)
- ConnectionTracker::closeConnection — fill server_id/pid/activity_id/etc.
defaults on partial connection arrays before comparisons
- ShutdownHandler — empty()-guard redis_handler so a null settings cache
no longer throws "array offset on null"
- SeriesService — coerce json_decode(bouquet/category_create_list) to array
before foreach (create + update variants)
- PlayerApiController — coerce movie_properties to array and ?? its keys
- TableController — ?? "" on movie_image before strlen (2 sites)
- admin/api.php — init $rInfo codec defaults for audio-less streams and
?? 0 the iostat avg-cpu chains
- post.php: guard range() so 'check_compatibility' no longer throws a ValueError when the stream count is below the 1000 step (range(0,N,1000) with N<1000 fatals on PHP 8.1+); fall back to a single [0] batch.
- ConnectionLimiter: only call ConnectionTracker::removeFromQueue() when the connection has a pid, and cast it to int — a null pid was raising a TypeError right in the live stream auth path (live.php -> validateConnections).
- player series.php / movies.php: read content/tmdb_popular defensively (missing file -> file_get_contents false -> 'array offset on bool'); guard the paginator against an empty $rPagination (undefined keys 0 and -1).
- FileLogger: mkdir the log directory before writing, so the file log channel does not silently fail when tmp/logs is absent (which also masked other errors).
- migration 010: ADD COLUMN IF NOT EXISTS for shared_mount_prefixes, matching migrations 002/005/007 — the column already ships in database.sql, so the non-idempotent ALTER failed with duplicate-column (1060) on every status pass and never recorded as applied.
FanoutClient::status() (control socket present AND answering) recorded into each server's watchdog_data every cycle (cross-server, main+LB; last_check_ago = heartbeat). Dashboard Service Status flags a red 'xc_fanout down' item for any fresh-reporting server whose fanout is not running.
Namespace-migration find/replace (XC_VM -> \XC_VM) leaked a backslash into
many plain string literals: CLI echo/log text, the MaxMind/GitHub outbound
User-Agents, the LB tarball temp path, process-match greps, the sysctl and
cron markers, the proxy ini section, and docblocks.
Most are cosmetic or self-consistent, but two carried upgrade hazards on
already-deployed servers, handled with migration guards:
- sysctl marker: writer + first-line check moved to `# XC_VM` together
(one-time benign rewrite on upgrade).
- cron marker: switched to `# XC_VM` and added a purge to both root-crontab
installers that drops lines with either the old `# \XC_VM` or the new
`# XC_VM` marker before re-adding, so upgrades don't duplicate cron jobs.
The xc_vm user crontab already does a full `crontab -r` rebuild.
Apply the new phpcs + Slevomat ruleset across src/ (phpcbf): 85 files. The bulk
are unused `use` imports that PHP-CS-Fixer's no_unused_imports missed (class
name only present in a PHPDoc description), plus blank-line normalization around
the use/namespace blocks. Verified safe: `make phpstan` stays green (0 errors) —
no import used in code or a real docblock type was removed.
Two distinct production faults from the panel logs:
Root — cold/absent file cache (MAIN warnings). The streaming bootstrap read
the servers/blocklist/proxy caches with no default, so when a cache file is
not yet built (fresh boot, cleared tmp, before the first cache build) they
came back null. Every downstream consumer that iterates or in_array()'s them
then warns — or fatals on PHP 8 (`in_array($x, null)`, `array_key_exists(...,
null)`). The two logged sites (StreamRedirector foreach, auth.php enable_proxy
offset) were just the ones that happened to hit it.
- LegacyInitializer::initStreaming(): default every cache-read global to an
array (matching the existing rBouquets guard). A cold cache now fails closed
(no servers → not-on-air for that request) instead of warning/fatalling.
- Defence-in-depth at the two use sites: StreamRedirector normalises $rServers;
auth.php null-coalesces $rServers[SERVER_ID]['enable_proxy'].
LB Redis fatal — ConnectionTracker::getCapacity(). A dropped LB→MAIN socket
makes phpredis multi() return false (its 30s-debounced ping misses the drop),
and zCard() on that bool fatals outside the RedisException catch, taking down
the watchdog. Guard the multi() result and throw RedisException so the existing
reconnect+retry handles it; a still-dead Redis degrades to a 0-count cycle.
The PHP SignalSender byte-path overlay class had 0 callers after E3 moved
the admin "send message" feature into xc_fanout (drawtext on the viewer's
HLS segment / TS window via FanoutClient::sendSignal -> POST /signal/<uuid>).
Delete the class and scrub its now-dangling mentions:
- src/Streaming/Delivery/SignalSender.php: removed (git rm)
- FanoutClient.php: comment reworded (legacy PHP byte-path, not the class)
- docs/{en,ru}/development/streaming-subsystem.md: dropped the tree line
- docs/adr/0003: overlay is e2e-proven on the LB; note the drawtext-ffmpeg
selection gotcha (bundled 8.0/7.1 lack the filter)
Complete the client cutover: HLS is now served to clients ONLY by the daemon, and
the admin "send message" text overlay (previously burned in by the PHP byte path)
moves to the daemon so the cutover doesn't regress it.
E3 (client HLS = daemon-only):
- live.php m3u8 arm: drop the legacy generateHLS fallback — a fed stream serves the
daemon's in-RAM playlist (plain or AES-128), else not-on-air (like the TS arm).
- segment.php: LIVE segments must be daemon tokens ("<id>_d<seq>.ts") → X-Accel to
the daemon; a non-daemon LIVE segment now 404s instead of serving on-disk tmpfs.
Removed the dead legacy LIVE branch (encrypt-on-read + /xc_hls/) and its orphaned
SignalSender/LegacyInitializer/AsyncFileOperations imports; kept the ARCHIVE
(timeshift) path untouched. Cleaned empty-if/else guard artifacts along the way.
- On-disk HLS stays (Phase F cancelled) only for timeshift/thumb/.analyse/Monitor —
never served to clients.
Send-message overlay via the daemon (feature parity — see the daemon's 0.9.0):
- FanoutClient::sendSignal → control POST /signal/<uuid>; InternalApiController's
signal_send pushes there (legacy tmpfs signal file kept as a harmless no-op).
- segment.php passes ?c=<uuid>&vc=<codec> and live.php passes &vc=<codec> so the
daemon can burn the banner onto that viewer's HLS segment / TS window.
- service launches the daemon with -ffmpeg (newest bundled) + -font free-sans.ttf.
php -l + make gates green.
E2 deleted live.php's non-proxy chase-read — the only user of SegmentReader
(playlist segment extraction). Remove the now-dead class, its unit test, and the
doc references. SignalSender (still used by segment.php) and CacheReader (used
widely) are kept.
Daemon-served viewers left the byte path at X-Accel, so live.php's chase-read
loop never measured their transfer rate and lines_live/lines_divergence stayed
blank for them (blinding the admin speed display + rate checks). Close the gap
from the daemon side:
- FanoutClient::connectionRates() reads the daemon's control GET /rates
(uuid => avg KB/s since attach).
- FanoutSyncCommand::writeDivergence() runs each reconcile cycle: compares each
pid=0 viewer's rate to the stream's expected bitrate (bitrate/8*0.92 — the
identical math UsersCronJob uses) and batch-writes lines_divergence (uuid) plus,
in non-redis mode, lines_live (activity_id). reconcile() now shares the same
fetched rows.
Legacy (non-daemon) viewers keep their tmpfs-driven UsersCronJob path; the two
never overlap (a daemon viewer has no speed file; a legacy viewer isn't pid=0),
so there is no double write.
A daemon restart (deploy/crash) wipes its in-memory registry; a still-running
ffmpeg tee keeps writing to the now-dead ingest socket (onfail=ignore), so every
ingest-fed stream silently drops off the daemon and delivery falls back to
legacy until the stream's ffmpeg is restarted. On-demand streams self-heal on
the next viewer; 24/7 streams needed a manual re-warm.
cron:streams now detects this in the running-stream branch: if the daemon is
reachable but returns 404 for the stream (new FanoutClient::daemonStreamMissing —
distinct from isStreamFed, and NOT triggered when the daemon is simply down), it
restarts the stream via startMonitor so buildLive re-registers the ingest.
Throttled by a <id>_.refeed stamp (120s) so a stream whose ingest keeps failing
is not restart-looped. Proxy streams have no local ffmpeg and never reach this
branch, so they are untouched.
Audited the streaming subsystem for symbols with zero real callers (ruled out
dynamic dispatch: command-name strings, routes, #[ListensTo], self::/$this->
internal calls, bare-name string dispatch). Removals, each grep-verified across
all of src/:
- Whole class TS (src/Streaming/TimeshiftClient.php) — superseded by the inline
TS byte-parsing in LLOD/Loopback; only ref left was a stale comment. Drops its
2 require + 3 use lines too.
- StreamUtils::getTSInfo — 0 callers.
- SegmentReader::getLLODSegments — replaced by the LLOD-v3 daemon feed; 0 callers.
- ProcessChecker::isPIDRunning + isPIDsRunning (dead pair) — 0 callers; drops the
now-orphaned CurlClient import.
- ProcessManager: checkPidFile + matchesCmdline (dead pair), killByPattern,
countProcesses, currentPid, releaseCronLock — all 0 callers (acquireCronLock
stays; locks self-release on exit).
- AsyncFileOperations: checkFilesExists, awaitFileExistsAdaptive, awaitFileModified,
getCacheStats, filterExistingFiles — never-wired public helpers, 0 refs.
php -l clean on all touched files; make gates green; no phpstan-baseline entries
reference the removed symbols.
Close Phase B's unencrypted-only limitation: encrypted HLS is now served from
the daemon's in-RAM segmenter too, so encrypt_hls streams no longer need the
legacy tmpfs .enc path — one of the two prerequisites for dropping the
streaming tmpfs (Phase F).
- StreamProcess: generate the stream's HLS key/iv BEFORE registering the daemon
ingest (moved writeStreamKeyIv up) and, when encrypt_hls is on, hand the hex
key/iv to the daemon at FanoutClient::registerIngest so it encrypts the HLS
segments it serves.
- FanoutClient::registerIngest: optional key/iv passed in the ingest PUT body.
- HLSGenerator::tokenizeDaemonPlaylist: prepend #EXT-X-KEY (URI /key/<token>,
IV from <id>_.iv) for encrypted streams, matching generateHLS.
- live.php m3u8: drop the !encrypt_hls gate — daemon HLS now covers both.
- segment.php unchanged: the daemon-segment X-Accel already returns the
daemon's (now-encrypted) bytes.
Box-validated: encrypt_hls=1 → 569.m3u8 has #EXT-X-KEY + tokenized daemon
segments; a segment decrypts (stream key + playlist IV) to a valid TS. Daemon
crypto is byte-identical to openssl. PHPStan clean, 443 tests. Daemon 0.6.0.
Refs ADR 0003 (Phase B encrypted). Daemon: XC_VM_Binaries 0.6.0.
Phase C (ADR 0003): fix the connection-limit leak for daemon-served live TS.
Under X-Accel the auth PHP-FPM worker returns immediately, so UsersCronJob's
reaper — which closes a TS row when its pid stops being a running php-fpm
worker — can neither track nor time out a daemon viewer (its recorded pid is
a reused, still-alive worker). Those rows leaked, filling the line's slots
until a false "max connections reached".
Fix (light P4):
- live.php marks daemon-served TS rows pid=0 (decided once per request via
$rTSDaemon: proxy $rFanout, or non-proxy isStreamFed), and passes the
connection uuid to the daemon in the X-Accel URL (?c=<uuid>).
- daemon (0.5.0) tracks connected uuids per stream and serves them at
control GET /connections.
- new fanout_sync daemon reconciles pid=0 open rows against that set every
~10s, closing any whose uuid is no longer connected (20s connect-grace);
skips reconcile when the daemon is unreachable (added to service boot()).
- UsersCronJob leaves pid=0 TS rows to fanout_sync (reaper would leak or,
on worker recycle, close them early).
HLS was already self-heartbeating (playlist re-fetch → updateLive), so this
targets long-lived TS. Box-validated: viewer → pid=0 row + uuid in
/connections; disconnect → daemon drops the uuid and fanout_sync closes the
row (slot freed). PHPStan clean, 443 tests. Daemon: XC_VM_Binaries 0.5.0.
Refs ADR 0003 (Phase C).
Phase C parity (ADR 0003): restore the legacy startProxy off-air behaviour
for daemon-served proxy streams. After registering a proxy source, live.php
calls FanoutClient::probe($id, on_demand_wait_time*1000) → daemon
POST /probe/<id>, which prewarms the puller and waits for the source to
produce data. If it stays dead, live.php shows the not-on-air page instead
of X-Accel'ing the viewer onto an empty/hanging daemon stream. A warm stream
(already producing) returns immediately, so only a cold/dead source pays the
wait — same as the legacy path.
Non-proxy off-air was already covered (the process block waits for the
playlist, and A3 gates on isStreamFed/has_data).
Box-validated: dead-source proxy → not-on-air video after on_demand_wait_time
(20s here); live-source proxy → fast play via the daemon (4.4 MB valid TS).
PHPStan clean, 11 FanoutClient tests.
Refs ADR 0003 (Phase C). Daemon: XC_VM_Binaries 0.4.0.
Phase B (ADR 0003): HLS is now served from the xc_fanout daemon's in-RAM
HLS end-to-end for unencrypted streams, PHP doing only auth — the stepping
stone toward dropping the streaming tmpfs.
- live.php m3u8 branch: when the daemon is reachable, the stream is fed
(isStreamFed) and encrypt_hls is off, fetch the daemon playlist
(FanoutClient::hlsPlaylist → client socket) and tokenize its <seq>.ts
entries (HLSGenerator::tokenizeDaemonPlaylist) into the same /hls/<token>
auth'd URLs as the legacy path, with the segment marked <id>_d<seq>.ts.
Else the legacy on-disk generateHLS runs (encrypted / daemon down).
- segment.php: a daemon-segment token runs the same uuid + IP checks, then
X-Accel-Redirect: /xc_fanout_hls/<id>_<seq> → daemon /hls/<id>/<seq>.ts.
- nginx: internal location ^~ /xc_fanout_hls/ → proxy_pass daemon (two path
segments, id_seq, to avoid the server-level rewrite hijack — same reason
as /xc_fanout/ and /xc_hls/).
The daemon's HLS on-demand lifecycle was already closed (0.2.0 touch+reaper).
Box-validated: 569.m3u8 → daemon MEDIA-SEQUENCE + tokenized segments; a
segment fetch → 200 valid TS (953 KB) from the daemon's RAM. PHPStan clean.
Limitation: daemon HLS is plain mpegts → unencrypted streams only; encrypted
streams stay on legacy tmpfs HLS. Daemon-side encryption + removing the tee's
on-disk HLS slave are what Phase F needs to drop the tmpfs.
Refs ADR 0003 (Phase B).
Completes Phase A (ADR 0003): non-proxy live streams now both feed the
daemon (A2 tee) and are SERVED by it. At live.php's non-proxy delivery
point, when the daemon is reachable and FanoutClient::isStreamFed($id)
(control GET /streams/<id> → has_data), hand the byte path to nginx→daemon
via X-Accel-Redirect: /xc_fanout/<id> — like proxy mode — instead of
pinning the worker in the per-viewer .ts chase-read. Daemon down / not fed
⇒ the legacy chase-read runs (the reachability rollback).
The tee feeds the daemon from the same ffmpeg that writes the on-disk HLS,
so by delivery time (playlist ready) the daemon has data. Ingest-fed
streams report running:false (no puller), so the gate is has_data.
Box-validated: 6 concurrent non-proxy viewers on stream 569 all served by
the daemon (daemon_conns=6) with php-fpm workers flat (not 1-per-viewer),
valid TS. PHPStan clean, 10 FanoutClient tests.
ADR 0003: A3 done; added a ⚠️ TODO that LLOD v3 (LlodCommand, streams.llod=2)
has no daemon path (separate PHP segmenter) and must be integrated before
Phase D — llod=0/1 already covered via buildLive+tee.
Refs ADR 0003 (Phase A).
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).
Route proxy-mode live TS through the xc_fanout daemon behind the
`live_fanout` settings flag, taking PHP out of the byte path. When the
flag is on, live.php runs its existing auth unchanged, then instead of the
socket_read→echo relay it registers the source with the daemon over the
control unix socket and hands the viewer to nginx via
`X-Accel-Redirect: /xc_fanout/live/<id>` — the FPM worker is freed
immediately, same pattern as P1 HLS delivery. Flag off (the default) keeps
the legacy relay; a failed registration falls through to it in-request, so
a down daemon degrades gracefully.
- FanoutClient: control-socket client (PUT/DELETE /streams/<id> over
FANOUT_CTL_SOCK via cURL unix socket) + buildSource(), a pure mirror of
ProxyCommand's stream_source/user_agent/proxy/cookie extraction whose
JSON matches the daemon's streamConfig.
- Paths: FANOUT_RUN_PATH / FANOUT_CTL_SOCK / FANOUT_HTTP_SOCK under /run.
- nginx: internal `location ^~ /xc_fanout/live/` → proxy_pass unix daemon
client socket (buffering off, long read timeout).
- FanoutClientTest: 8 cases covering source-config parity and the
empty-urls short-circuit.
Refs ADR 0002. Daemon lives in XC_VM_Binaries (not this repo).
Fixes warnings/exceptions from production panel logs (v2.3.9), mostly
unguarded access to keys of external provider payloads and to files that
can vanish under races:
- ProvidersCronJob: suppress file_get_contents noise on unreachable
providers (readURL), and normalise each stream row with defaults
(`+= [...]`) so missing stream_id/category_id/name/stream_icon/
epg_channel_id/container_extension no longer warn; skip rows with no
stream_id. Guard category_id/category_name in the category feeds.
- StreamRedirector: guard the cached stream_ file read (missing cache
file no longer warns or produces a half-built $rStream), and default
info.direct_source/direct_proxy.
- stream/probe.php: guard $rChannelInfo (redirectStream may return false)
and the decoded stream_info before reading codecs/container/bitrate.
- admin/live.php: @filesize on a segment .ts that may be rotated away
mid-read.
- PlayerApiController: default parse_url()['path'/'host'] and
$_SERVER['HTTP_USER_AGENT'].
- UserRepository::applyIspInfo: initialise isp_asn default so it is never
read undefined when the GeoIP lookup returns no ISP.
- admin/providers.php: default max_connections/active_connections for
providers whose last fetch failed.