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.
Replace fully-qualified \XcVm\... class references (in code and in
docblocks/@see/@param/@return/@throws) with short names backed by
top-of-file use imports, project-wide. Same-namespace references drop
the prefix with no import; view templates gain the top-level imports the
check-procedural-use gate expects. Purely mechanical, no behavior change.
A new monitor takes a few hundred milliseconds to write its _.monitor file.
Every viewer that reached a stopped on-demand stream inside that window found
it unwatched and started it again: the later viewer deleted the _.monitor and
_.pid the earlier monitor had just written, each monitor's checkRunning ran
before the other had set its process title, and both launched a producer —
two connections to a source that often allows one, kicking each other off.
The check-and-start in live.php now runs under a per-stream flock
(<id>_.start on the streams tmpfs). The viewers behind the first wait on it,
re-read the monitor pid, and find the stream started. A viewer that dies
releases it with its process. The test runs four simultaneous viewers in
separate processes: one start with the lock, four without.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuZvjFSdodqgpyXtaoH1Xt
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>
The daemon reads a stream's codecs and picture size off the bytes it fans out
and reconcileSupervised copied them into `video_codec`, `audio_codec`,
`resolution` and `bitrate` — but not into the `stream_info` JSON, which is the
shape the rest of the panel actually reads. A supervised stream therefore
showed "? x ?" and "N/A" in the streams list; worse, every adaptive variant was
dropped from the master playlist for want of a width, and stream/auth.php fell
back to calling every stream h264 when handing the viewer its codec.
The JSON is now written beside the columns, merged rather than replaced, so
whatever ffprobe once found that the daemon does not read (frame rate,
container) survives, and an unchanged reading writes nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZP7fc2Hz36wbPmuLd9F9o
Two reasons the native remuxer never ran on a real panel, both in the
eligibility check:
- it compared `type_key` against `live_streams`, which is no type at all —
`streams_types` holds (1, 'Live Streams', 'live'), (3, 'created_live'),
(4, 'radio_streams'). Every ordinary live channel was refused, so
`fanout_source_backend` native/auto silently kept running ffmpeg. The new
log line said it out loud ("ffmpeg runs this stream: not a live channel"),
which is how it surfaced; the refusal now names the type it saw.
- `gen_timestamps` and `read_native` were treated as "the operator asked for
timestamp repair / realtime pacing", but both DEFAULT to 1 in `streams`, so
they carry no intent and refusing them refuses everything. -re paces a
file-ish input, which a passthrough of a live source does by itself, and
genpts only synthesises timestamps a source failed to send — a source that
broken has no usable video clock either, which ends the run with exit 3 and,
in `auto`, hands it to ffmpeg.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K48c64npichw9ZCZDU16ja
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
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
Three places where the panel and the fanout daemon disagreed about who
owns a stream's encoder. The first is a showstopper; the second is a
self-inflicted outage; the third is silent.
* stopStream could not stop a supervised stream. It killed the encoder
pid -- which is precisely the event the daemon's supervisor exists to
react to, so the daemon started a replacement and the stream came
straight back. It now releases supervision FIRST, so the daemon lets go
and kills the encoder itself. (The existing FanoutClient::unregister
call drops the ingest registration, which is a different thing.)
* The rogue-ffmpeg sweep in cron:streams kills every ffmpeg writing an
.m3u8 that is not on its active-pid list. That list is built from the
database pid, read at the top of the pass -- so an encoder the daemon
restarted a moment later was not on it, and the sweep would shoot a
perfectly healthy supervised stream and log it as a rogue. The daemon's
own reported pid is now added to the list.
* force_stream wrote a `<id>.force` signal file, which only
MonitorCommand ever read -- and it stands down for supervised streams.
Forcing a source therefore did nothing at all. It now calls the daemon
and falls back to the file when the daemon does not hold the stream or
cannot be reached.
All three degrade to exactly the previous behaviour when the daemon is
unreachable.
Verified: php -l clean on the changed files and across all of src/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Regular live HTTP(S) streams got no reconnect resilience — only the LLOD
path did. A flaky upstream that briefly drops the connection makes ffmpeg
exit ("Stream ends prematurely"), and the watchdog then kills and restarts
the whole stream, so marginal sources flap (start "not on the first try,
sometimes never"). Verified live: stream 560 (a slow XC_VM source) ran
ffmpeg at 0.5x with the HLS playlist stalling ~100s before each watchdog
restart, while a fast-CDN stream on the identical pipeline stayed up 21h.
Broaden the reconnect guard from `$rLLOD && ...` to every non-loopback
http(s) source: `-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5`.
Keep `-fflags +discardcorrupt` scoped to the on-demand (LLOD) path where it
originally shipped. Flags stay before `-i` (http-demuxer input options) and
are still guarded to http(s) — they are a fatal "Option not found" on
udp/rtmp/file inputs.
Replace SettingsManager::getAll()['key'] with SettingsManager::get('key') in the
domain layer (enable_cache, redis_handler, player_allow_hevc, download_images,
movie_year_append, save_restart_logs, mag_default_type, channel_number_type).
get() preserves the raw value so existing comparisons (== 1, != 0, != 'bouquet')
are unchanged, while adding a default to avoid undefined-key warnings.
ProxyCommand was removed in E1, so no process is ever titled XC_VMProxy[<id>]
anymore — the process-name checks in ProcessManager::isMonitorAlive and
StreamProcess::stopStream that matched it can never be true. Remove the dead
references; the real titles (XC_VM[], LLOD[], Loopback[], the m3u8/ts patterns)
are untouched.
Not touched: the live.php non-proxy .ts chase-read (E2) and HLSGenerator
generateHLS / segment.php tmpfs-serving (E3) — those are the active
daemon-reachability fallback (daemon down, re-feed windows, and the
proxy/restreamer/llod2 types not yet canary-validated), so deleting them now
would remove the rollback. Gated on Phase D stability across all types.
Phase E (ADR 0003), step 1: proxy live streams are now daemon-only. The
daemon owns the proxy puller (registered + off-air-probed in live.php), so
the legacy producer + relay are dead code and are removed:
- delete Cli/Commands/ProxyCommand.php (the XC_VMProxy producer).
- remove StreamProcess::startProxy and ProcessManager::startProxy (only
caller was live.php).
- live.php: drop the startProxy branch in the process block and the
AF_UNIX socket relay in the proxy delivery arm. A proxy stream whose
daemon is unreachable ($rFanout false) now shows not-on-air — the
keepalive restarts the daemon in ~2s — instead of falling back to the
legacy producer.
This removes the proxy fallback: the daemon is required for proxy delivery
(its own crash resilience is the keepalive). Non-proxy legacy stays for now.
PHPStan clean, 443 tests.
Refs ADR 0003 (Phase E).
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.
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).
- Remove an orphaned docblock (it described createChannelItem but sat above
buildSubtitleImport, left behind by an earlier extraction).
- Refresh buildLive()'s docblock: it is now the sole command builder, not a
shadow — the "run in shadow before it replaces the inline assembly" note was
stale after the flip (0bd6a9dc).
- deleteCache(): if(!empty){…}else{return} -> early-return guard clause.
- updateStream()/updateStreams(): extract the identical check-then-insert of the
cache-invalidation signal into insertCacheSignalOnce(); also drops the
redundant double return in updateStreams and the unconditional getMainID()
when caching is off.
Documentation/cosmetic + a behaviour-preserving dedupe. Refs: #148
Verified: php -l, make phpstan (No errors), make gates. Serve check on the hot
updateStream path (warm 567 -> m3u8 200/#EXTM3U).
Completes the in-progress FFmpeg-command migration. startStream's ~180-line
inline command assembly (and the shadow buildLive() diff-check that ran beside
it) are gone; the command is now built solely by the pure buildLive(), fed the
raw stream row. The delay-playlist bookkeeping (segment start + sleep window)
is hoisted just above the call since buildLive() consumes segmentStart /
delayActive. Net -171 lines.
buildLive() already did all transcode-attribute resolution and {TEMPLATE}
substitution internally, so feeding it the raw row (instead of the
post-mutation row the shadow used) removes a latent double-mutation in the
custom-transcode branch (transcode_profile_id == -1): json_decode((string)
$array) on an already-decoded value would have yielded [] there. Only the
inline path ever ran, so this was never a live bug — but it made the shadow an
invalid equivalence probe for that branch, which is why the flip is verified
directly rather than via the shadow log.
Equivalence verified two ways:
- Structural: the 129 command-building statements of buildLive() are
line-for-line identical to the inline block (the sole non-command delta was a
dead `$rFFPROBE = FFPROBE_BIN_40` assignment that never entered the string).
- Empirical: on a live box, the generated _.ffmpeg is byte-identical
(md5-equal) before vs after the flip for BOTH a plain stream and a
custom-transcode stream (enable_transcode=1, profile_id=-1, custom attrs),
and the flipped stream serves (m3u8 200/#EXTM3U, ts 200 ~2.8MB).
Refs: #148
Verified: php -l, make phpstan (No errors), make gates.
- pidFromFileOrColumn() replaces stopStream's two identical "read the PID from
the sidecar file, else fall back to the streams_servers column" blocks (the
monitor_pid and pid lookups). stopStream no longer needs its own $db handle.
- resetStreamServerRow() replaces the 11/12-column NULL-reset UPDATE duplicated
in stopStream (with monitor_pid) and stopMovie (without) — the difference is
now a $rWithMonitor flag.
Pure mechanical extraction, behaviour preserved. Refs: #148
Verified: php -l, make phpstan (No errors), make gates. Canaried live: warmed
stream 567 (pid+monitor running, sidecars present) then StreamProcess::stopStream
(567, true) -> row reset to pid/monitor NULL + status 0 and all _* sidecars
removed.
Two byte-identical blocks were copied across the stream launchers:
- writeStreamKeyIv() — the AES-128-CBC _.key/_.iv sidecar generation,
repeated verbatim in startStream, startLoopback and
startLLOD (3x)
- clearStreamPidSegments() — the "rm _*.ts + unlink stale _.pid" preamble,
repeated in startLoopback and startLLOD (2x)
Pure mechanical extraction, no behaviour change. Refs: #148
Verified: php -l, make phpstan (No errors), make gates. Canaried live via a
warmed stream (567): startStream produces a 16-byte _.key and 16-byte _.iv and
the m3u8 serves (HTTP 200, #EXTM3U).
Phase 1 of extracting the live ffmpeg command assembly out of startStream.
buildLive(array $data): string is a byte-faithful copy of the inline assembly,
fed from a prepared $data array instead of loop-local state, with the delay
playlist I/O and segment-start/sleep left in startStream (arriving via
$data['segmentStart']/['delayActive']). The one non-verbatim change is the
ac3/eac3 dts_legacy bin switch, now a local reassignment instead of mutating the
$rFFMPEG_CPU/$rFFPROBE globals (the $rFFPROBE write was already dead). Homed on
StreamProcess for now so the private output/logo/aac helpers resolve via self::;
a FFmpegCommand facade is a follow-up.
startStream computes buildLive() alongside the inline assembly and error_log()s
any divergence, but still executes the inline $rFFMPEG -- shadow mode, no flip.
Once the diff log stays empty on real traffic the call site can switch over.
Characterisation tests cover simple/custom_ffmpeg/loopback/delay/rtmp branches
and assert no unresolved {TOKEN} survives.
Verified: phpstan level 5 green, phpunit 397/397, make gates green.
Preparation for extracting the ffmpeg command assembly into a pure builder: the
delayed-HLS branch interleaved file I/O (reading the delay playlist, the
short-playlist abort, merging the old playlist) with string building. Hoist the
I/O and the segment-start/sleep computation ahead of the output assembly, behind
an explicit $rDelayActive flag, leaving the two output-append branches as pure
string building that consumes the precomputed $rSegmentStart.
Behaviour is identical (the I/O ran in the same delay-only case before, just
lower down); this only untangles side effects from string assembly so the
assembly can move to a testable, shadow-diffable FFmpegCommand::buildLive.
Verified: phpstan level 5 green, phpunit 391/391, make gates green.
The delayed-HLS branch inline-computed two things: the resume segment number
parsed from the existing delay playlist (last-or-previous line's _<n>.ts index),
and the delay sleep seconds (delay_minutes*60 reduced ~10s per already-produced
segment, floored at 0). Pull both into pure resolveDelaySegmentStart() and
resolveDelaySleepTime() helpers with unit tests. The playlist merge and the
short-playlist abort stay inline. No behaviour change.
Verified: phpstan level 5 green, phpunit 391/391, make gates green.
The ffprobe-skip branch of the source loop hand-rolled two things: a scan of the
stream arguments for skip_ffprobe == 1, and the literal assumed h264/aac mpegts
result used when the scan matched. Give both names via pure helpers and cover
them with unit tests. No behaviour change.
Verified: phpstan level 5 green, phpunit 385/385, make gates green.
The 300s ffprobe cache is written, existence-checked and deleted under
md5($rSource), but the content read used md5($rStreamSource)
(= parseStreamURL($rSource)). When parseStreamURL rewrote the source the read
fetched a different, usually missing file, so igbinary_unserialize(false)
failed the validity check and every start re-probed instead of hitting the
cache. Read under md5($rSource) so the key matches the write.
Verified: phpstan level 5 green, phpunit 382/382, make gates green.
The {AAC_FILTER} substitution carried the rule inline: emit -bsf:a
aac_adtstoasc only when a copied AAC audio stream is muxed into a non-FLV
container. Give that rule a name and a test via a pure aacBitstreamFilter()
helper. No behaviour change.
Add unit tests: applied for copied AAC in mpegts/empty container, skipped for
FLV, skipped when the codec is not AAC or the output is not a copy.
Verified: phpstan level 5 green, phpunit 382/382, make gates green.
The codec-metadata derivation persisted for a started live stream (player
compatibility, audio/video codec names, resolution snapped to the nearest
standard height) was an inline block reading ffprobe output. Pull it into a
pure resolveStreamCodecMeta() helper taking the ffprobe array and the
player_allow_hevc flag and returning [compatible, audio, video, resolution].
The only external input, SettingsManager::getAll()['player_allow_hevc'], is now
passed in (getAll() just returns the cached settings array, so hoisting the read
out of the guard is free). Add unit tests: non-array/missing-codecs defaults,
compatible h264/aac, resolution snapping, HEVC gating by the allow flag, and
incompatible-but-reported codecs.
Verified: phpstan level 5 green, phpunit 379/379, make gates green.
The X-XC_VM-Detect / X-XC_VM-Prebuffer header injection was duplicated three
times in startStream: each block looped the argument list to append the header
to an existing 'headers' entry and added a new one if absent. Collapse the three
copies into a single appendHeaderArgument() helper and drop the shared
$rProcessed flag they threaded through (it leaked state between the blocks).
Behaviour is unchanged: the helper uses a local applied flag, and the three call
sites produce the same argument lists as before. Add unit tests covering
append-to-existing, create-when-absent, append-to-every-headers-entry, and the
empty-list case.
Verified: phpstan level 5 green, phpunit 373/373, make gates green.
Continue breaking down StreamProcess::startStream:
- extract resolveProbeSettings() (ffprobe/analysis timing) and
rotateSourcesPastCurrent() (source-failover ordering) as private static
helpers, with unit tests locking in their behaviour;
- simplify 15 inverted empty-if/else blocks across the class into a single
positive condition (no behaviour change).
Also fix the latent undefined-variable bugs the refactor surfaced under PHPStan:
startStream assembles the ffmpeg command from state produced inside the source
failover loop (which closes before the command is built) and inside the
non-custom_ffmpeg branch. On an empty source list, or when a custom ffmpeg line
is used, several variables were read while possibly undefined -- masked at
runtime only by ternary guards and suppressed in the PHPStan baseline. Default
them at the two scopes that own them:
- loop-scoped, read after the loop: $rSource, $rRealSource, $rStreamSource,
$rProtocol, $rFFProbeOutput
- non-custom_ffmpeg-branch-scoped, read in the {MAP}/{GEN_PTS}/{READ_NATIVE}
substitution and delay sleep: $rMap, $rGenPTS, $rReadNative, $rSleepTime
and drop the 9 now-obsolete entries from phpstan-baseline.neon.
Behaviour is unchanged on the happy path (the loop/branch overwrite the
defaults) and the defaults match the existing runtime ternary-guard results.
Verified: phpstan level 5 green (whole project, entries un-suppressed),
phpunit 369/369, make gates green.
buildSubtitleImport nested the metadata loop inside the import loop and reused
the outer counter $i. The inner loop advanced $i to count(files), so the outer
import loop exited after its first iteration: only the first subtitle was
imported (-sub_charenc -i), while -map/-metadata was still emitted for every
file — mapping ffmpeg inputs that were never added. Multi-subtitle movies got a
single imported track plus dangling -map references (broken output).
Split the nested loop into two siblings — import every file, then map every
file — and cache the count. All configured subtitles are now imported and
mapped to the correct input index.
Add tests/Unit/StreamProcessSubtitleTest.php covering empty input, a single
local subtitle, remote-server fetch, and the multi-subtitle case that
reproduces the defect (import count was 1, must equal N). Verified red→green
against the pre-fix code.
Verified: php -l clean, make gates green, phpunit 336/336 green.
Move the VOD subtitle import + metadata assembly out of startMovie into a
private helper so the method is smaller and the subtitle logic is isolated.
Behaviour is preserved verbatim, INCLUDING a pre-existing bug: the inner
metadata loop reuses the outer import loop's $i counter, so the import loop
effectively runs once (first file) while metadata is mapped for all files.
Left as-is here; it is a real defect to fix in a dedicated change.
Verified: php -l clean, make gates green.
createChannelItem, startMovie and startStream each inlined the same blocks
verbatim. Extract them into private helpers — removing the duplication and
shrinking the three oversized methods:
- buildLogoFilterOptions() — logo / -filter_complex overlay block (was inlined 3x)
- resolveGpuInputCodec() — CUVID hardware-decoder detection (was inlined 2x)
- applyDefaultCopyCodecs() — default -acodec/-vcodec to stream copy (was inlined 3x)
Pure extraction, behaviour preserved. startStream's GPU block is intentionally
left inline: it reuses the already-computed $rFFProbeOutput instead of
re-probing the source, so it is not the same code.
Verified: php -l clean, make gates green (PSR-4 use-imports, LB manifest,
vendor prod-only). net -28 lines.
The Stream Info column always showed a transcode speed of "1x" even though
ffmpeg's real speed fluctuates. Two independent bugs were involved.
1. Progress was never captured while a stream ran.
ffmpeg's `-progress` posted to the HTTP /progress endpoint, but ffmpeg
holds one open-ended chunked POST for the stream's whole life. PHP-FPM's
php://input can't read a length-less body, and nginx only invokes PHP
once the request body is complete, so the endpoint fired only at stream
end. `progress_info` therefore stayed empty and the view fell back to the
hardcoded "1x".
Switch ffmpeg to write `-progress` to a local file (the mechanism the
created-channel path already uses). StreamsCronJob now tails that file,
keeps the last complete report block, re-encodes it as the JSON the panel
expects, and truncates it each cycle so it stays tiny on disk.
2. Speeds just above 1 were swallowed by the display.
`floor($speed * 100) / 100` dropped e.g. 1.01 to "1x" because 1.01 * 100
is 100.9999… in floating point. Use round() instead, and apply the same
robust parsing to the radios path (which multiplied the "1.01x" string).
Files:
- Domain/Stream/StreamProcess.php ffmpeg -progress -> local file
- Cli/CronJobs/StreamsCronJob.php tail/parse/truncate the progress file
- Public/Controllers/Admin/TableController.php round() speed display
Null-coalesce absent keys in activity info, cached signal counts, streams_servers
maps, server protocol and PID-running results, and @-suppress the stale .pid
unlink. Avoids offset-on-null/undefined-key warnings under PHP 8 without changing
behaviour on present data.
- QueueCommand: adopt a live build PID instead of unlinking its .create
marker and relaunching (which killed the running encoder and reset the
build to 0% on every queue re-add); widen the PID capture window from
0.3s to 5s to survive console.php bootstrap
- VodCronJob: check build liveness locally via /proc cmdline instead of an
HTTP self-call that falsely reported the build dead whenever the internal
API or server_online flag hiccuped
- CreatedCommand: verbose CLI output (per-source status, encode % / speed
every 10s), local process checks instead of HTTP, cleanup of stale
_.create on the nothing-to-build early exit
- StreamProcess: pass -progress <id>_<md5>.progress to the channel-item
ffmpeg command; CreatedCommand parses it and mirrors the percentage into
streams_servers.progress_info
- TableController/api.php: "% DONE" now includes fractional progress of the
source currently being encoded
- MonitorCommand: stop type-3 (created) channels from falling through into
the LLOD branch after startStream — LLOD cannot consume raw
"s:<server>:<path>" sources and spammed "[LLOD] Invalid source" in a
restart loop; the on-demand LLOD shortcut now also skips type 3
- PHP 8 warning fixes along the same chain: missing on_demand key
(ChannelService), undefined $rSplit and missing parent_id column in
grouped queries (TableController), missing .create file (VodCronJob),
empty lines in _.list parsing and null cc_info (StreamProcess)
- StreamProcess: parse_url() output has no 'port'/'path' keys for default-port
URLs — probe URL built with isset() guards (477 warnings/8h in panel logs)
- StreamProcess: json_decode of transcode_attributes/profile_options can
return null — normalize to array before array_key_exists/array_merge
(TypeError at stream start)
- MonitorCommand: the .m3u8 playlist can vanish between the loop's
file_exists check and md5_file (stream restart race) — guard both call sites
Replace the fragile per-class setDb()/db() pattern (which threw when a
bootstrap path forgot to wire $db) with a shared trait that lazily resolves
from DatabaseFactory. Fixes the streaming UserRepository fatal and the same
latent issue in module crons. 42 classes converted.
- Exclude bundled third-party code from analysis (Modules/tmdb/lib/*,
Core/Util/MobileDetect.php) — like the vendored Composer packages, these
are not our code (php-tmdb client, Mobile-Detect). Removes 67 baselined
errors incl. the only class.notFound and phpDoc.parseError (both vendored).
- return.void (10): drop the discarded return value (return true/false/null →
return;) in void methods — NetworkUtils, LineService, StreamProcess, TMDbService.
- deadCode.unreachable (2): remove the dead `return;` after AdminHelpers::goHome()
(a never-returning call) in Plex/WatchController.
- Regenerate phpstan-baseline.neon: 440 -> 361 errors (291 -> 241 blocks).
Verified: make phpstan green, PHPUnit 298/298.