An adaptive master lists a variant only by the resolution and bitrate in its
stream_info, and stopping an on-demand stream for having no viewers cleared
it: an idle HD/FHD variant was left out of the master, and nothing could
then start it, so players only ever got the variants already running.
The two idle stops (OndemandCommand, the on-demand stop in cron:streams via
StreamProcess::stopStream(..., idle: true)) now keep stream_info; a manual
stop, a restart and every other stop still clear it.
Refs: #61
- A subtitle track's name went on ffmpeg's command line through
escapeshellcmd(), which leaves spaces alone: "English (SDH) forced"
became three arguments and the movie's encode failed. Each is one
escapeshellarg()'d argument.
- A quote in a created channel's source path ended the path in ffmpeg's
concat list, which then did not parse.
- Deleting a recording killed by grep 'Record[<id>]': bare brackets are
a character class, which never matched the process's title, and the
recording went on to its end.
- Archive cleanup took any entry whose name began with a stream's id for
that stream's archive: a recording's "<id>.ts" beside it was removed.
- The RTMP endpoint's shutdown never saw $db, so it closed nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the daemon takes -source_file but the 0600 source file cannot be
written, the native remuxer starts with the source URL and its fetch
options (headers, cookies, proxy) on its command line, readable in the
process list. That fallback was silent; it now leaves a line in the
stream's log, like every other way this code falls back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZT2uVUcEevH8MX65MYQpD
writeNativeSource() asked FanoutClient::supports(), which trusts a file
cache up to a minute old: after a daemon downgrade the panel kept writing
`-source_file` lines the old binary rejects (exit 2, an ordinary failure
there), and a start whose cache had aged paid a second GET
/monitors/state. The feature is now read once per hand-over from what
superviseStream() has just fetched (FanoutClient::supportsLive(), which
supportsRemux() now uses too), and writeNativeSource() takes the stream
id from its data instead of a second parameter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZT2uVUcEevH8MX65MYQpD
With the source backend on auto or native, every copy-only channel runs
on `xc_fanout remux`; a channel the remuxer serves wrongly, without
failing, had no way back to ffmpeg short of a custom command. The new
`streams.force_ffmpeg` (migration 088) keeps one channel on ffmpeg
whatever the backend: a switch on the stream's Advanced tab and in the
bulk edit, kept by the admin API's edit, carried to nodes in the R2
stream record (ReplicaSections::STREAM_FIELDS).
The switch is StreamProcess::remuxRefusal()'s, not nativeRefusal()'s:
source drivers are refused by the latter too, and ffmpeg cannot read a
driver's URL, so the switch would have left such a stream with no
producer at all.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`xc_fanout remux` took the source URL, cookies, headers and proxy as
flags, so the provider's account sat in /proc/<pid>/cmdline, which every
local user can read. With a daemon that advertises `remux_source_file`,
the panel writes them to `<id>_.source_<n>` (0600, one per source,
StreamProcess::writeNativeSource()) and passes `-source_file` instead. An
older daemon, or a file that cannot be written, keeps them in argv. The
file goes with the stream's other `<id>_*` files when it stops.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The movie source already honoured shared_mount_prefixes, but subtitles
stored on another server were always fetched through that server's
getFile API, even when the same path was mounted locally. Use
isLocallyMountedPath() for subtitles too, so a file under a configured
prefix that exists on this node is passed to ffmpeg directly; anything
else still goes over HTTP.
Refs: #167
Trailing whitespace in CacheEngineCronJob and FileCacheTest's member
spacing (phpcbf). The md5() writing the ffprobe cache names a file, as
every reader of that key does; it is marked as such for semgrep's
weak-crypto rule, like the repo's other non-security md5 names.
Readers (stream auth, player API, Ministra, Enigma2) read cache files with
no lock, while cron:cache_engine rewrote them in place with
file_put_contents(), and kills its own previous run with kill -9. A
request could read a truncated stream_/line_i_ entry; a killed worker left
one behind.
- FileCache::writeAtomic(): temp dotfile + rename(); set() uses it.
The temp name no longer matches the stream_*/line_i_* entry globs.
cleanStaleTemps() sweeps temps a killed writer left.
- cache_engine, StreamCacheBuilder, UserRepository's DB-fallback line
cache, the balancer and ffprobe caches all write atomically.
- A failed DB read no longer counts as "deleted": streams_update /
lines_update used to unlink the entries of every requested id when
the query failed, and a failed full-run page let the sweep delete
live entries. Workers now flag the failure and the sweep is skipped.
(Database::query() leaves $db->result set on a failed execute, so the
query's return value is checked.)
- Full-run paging is in id order (streams, lines before the MAG join,
episodes tie-broken by stream_id): unordered LIMIT pages could skip
rows, which the sweep then deleted.
- lines_update drops the old line_c_/line_t_ lookup files when a line's
credentials or token change or the line is deleted.
- Incremental mode removes series_/episodes_ files of deleted series and
of series left with no episodes (they used to keep stale lists).
- Change detection uses <= so an edit in the same second as the last
write is not missed; the kill of older runs matches the PID exactly.
- The empty-series run's temp files are merged and removed.
- CacheCronJob's startup wipe no longer unlink()s '.' and '..'.
YouTube refuses data-center IPs ("Sign in to confirm you're not a bot"),
and the media URL yt-dlp resolves plays only from the IP that resolved it.
yt-dlp never got the stream's proxy, so a YouTube source on a hosting
server could not start.
- parseStreamURL() takes the stream's "HTTP Proxy" and passes it to
yt-dlp (--proxy). Every caller hands over its stream's proxy: stream
start, source checks, the on-demand scanner, proxy streams, and the
admin probe/check tools.
- The proxy value the form asks for (ip:port) is now given to ffmpeg as
http://ip:port (StreamUtils::proxyURL). Without the scheme ffmpeg's
-http_proxy ignored it and fetched directly, so a stream proxy never
took effect.
- yt-dlp asks YouTube with player_client=default,android_vr: without a
JS runtime the default clients serve a live stream only as separate
video/audio, which -f best cannot pick; android_vr serves one muxed
HLS playlist.
- The node a probe runs on resolves a platform page itself, through the
probe's proxy; it used to ffprobe the YouTube HTML page.
- The probe tool sent the proxy as `proxy` but the controller read
`http_proxy`, so a probe never used it; the settings page saved the
default HTTP Proxy under argument_key `http_proxy` instead of `proxy`,
so it never saved.
Verified on the test server: yt-dlp through an HTTP proxy resolves a
live stream, ffprobe/ffmpeg play it through the same proxy, and the
segments fail without it (the URL carries the resolver's IP).
The bundled ffmpeg 8.0 needs glibc 2.35 and does not start on Ubuntu 20.04 or
Debian 11 (plan: per-distro ffmpeg, Stages 2-4). XC_VM_FFMPEG releases one
build per label and distribution, each built in that distribution's container.
- `console.php ffmpeg` (FfmpegBuildsCommand, as xc_vm) fetches 4.0, 7.1 and 8.1 for
this distribution (`ffmpeg_<label>_<distro>.tar.gz`), checks each against the
release's hashes.md5, extracts it aside, and swaps it into
bin/ffmpeg_bin/<label>/ only once ffmpeg and ffprobe start here; the
installed build stays otherwise. ffmpeg_version.json keeps each label's
release, so an unchanged one is not fetched again. cron:root_signals runs it
daily on every node, the first pass at once, and hourly while a launch
has not finished (a service restart in the same cron pass killed one on
the test LB).
- XUI's 4.0 is kept where a node has it: only it takes -nofix_dts, which the
legacy DTS path passes, until the rebuilt 4.0's DTS handling is validated.
A rebuilt 4.0 (BUILD_INFO beside it) is never passed -nofix_dts
(FfmpegPaths::fixDts).
- The 8.x label is 8.1: migration 057 moves the ffmpeg_cpu/ffmpeg_gpu settings
from 8.0, and a version a node lacks takes the newest build of its major it
has (8.0 until it fetched 8.1).
- GIT_REPO_FFMPEG, on the BIN update channel.
Phase 8 wants live_streaming_pass in no process's command line, and a node
still sent it to itself twice.
- The local RTMP output published with `?password=<live_streaming_pass>`,
in ffmpeg's command line; rtmp.php takes a publish from 127.0.0.1 without
a password at all, so the output names none.
- The recorder read its own /admin/live and /admin/timeshift with the
fleet's secret, which pulls any stream from any server. It presents a
LoopbackToken instead: HMAC-SHA256 under the node's own key
(config/cluster/loopback.key, made at first use, 0600) over the stream and
a 24 h expiry, which RelayGuard admits (LOOPBACK) for that stream from
127.0.0.1 or ::1 only, never as the password from elsewhere.
- A start RPC goes as one `stream.start {stream_id}` / `vod.start
{stream_id, force}` per stream, deduped per stream, to a node whose agent
says `typed_starts` (its cluster:exec --types lists stream.start). Any
other node keeps node.rpc, which an older node PHP understands.
cluster:exec runs them as node.rpc's starts did. The extension already
classes both as granting. recording.start needs no producer: a node
starts its due recordings from its replica.
- Past a fence's drain, cron:streams releases a stream's thumbnail and TV
archive workers with its producer, and nothing new starts: startThumbnail,
the thumbnail, archive and record commands, the queue daemon's movie and
channel passes (their rows wait) and cron:vod's due recordings. A running
VOD transcode or channel build finishes: killing it would fail the movie,
and the queue cannot hand a row back to pending.
- ADR 0004: those, and the stops and rotate_sign_key it still called unbuilt.
Past a fence's drain (plan, section 9, FENCED) the node already refused
every viewer, but its encoders kept running. cron:streams now releases
each running producer (StreamProcess::stopStream without the stop, so
the stream's record keeps its state), and startMonitor() starts nothing
while the fence stands, whoever asks. The first pass after the fence
lifts starts the streams again, as for any producer that died.
A supervised stream that goes down is written with status 1 or 2, never
0, so it stays selected for that restart.
Relays and file reads between nodes stop carrying live_streaming_pass
once a node's DATAPLANE flow is on:
- MAIN mints relay (rly) and file (fil) tickets for DATAPLANE nodes and
carries them in the R2 stream record outside its ETag and version, so
a ticket refresh never restarts an encoder.
- The parent's admin/{live,vod,timeshift,thumb} judge X-XCVM-Relay-Auth
(RelayGuard: panel signature, parent, stream, child generation, child
key over method and URI within ±90 s, unspent nonce) and refuse the
password from a DATAPLANE node.
- /xfile serves sealed, owner-bound files in chunks, each with a signed
X-XCVM-File-Digest.
- StreamProcess and the monitor/loopback commands build loopback URLs to
the agent's relay proxy when the flow is on.
- DATAPLANE joins MODE2_FLOWS and can be switched on the Cluster Nodes
page. Data-plane vectors live in tests/Support/cluster_dataplane_vectors.json.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rdv7hXd8RWhLHoj5Ma2ZqY
- On-demand proxy streams start ProxyCommand with fanout off instead of
a MonitorCommand that never loads direct sources.
- A second viewer no longer starts a second proxy that kills the first
(the live check now sees XC_VMProxy[id]).
- hlsPlaylist() honours the switch, and daemon segment tokens get 404
when fanout is off so players reload the on-disk playlist.
- The TS/HLS/start decisions are pure FanoutMode helpers with tests;
the live.php comment names the real rollback.
- A missing fanout_supervise reads as on in the panel too, matching the
daemon's config and Fanout ADR 0002.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rdv7hXd8RWhLHoj5Ma2ZqY
StreamProcess::driverProbe() stands in for ffprobe on a driver source: the on-demand scanner, the stream form's probe button and the stream tools check report 'Module source available / unavailable' from the driver's available(). Driver spec entries now carry probe_cmd = console.php source:probe <id> <base64 url>, so the supervisor returns to a higher-priority driver source too. Also fixes checkStream's count(null) TypeError when a probe fails.
CI runs its three checks over the whole tree, so the cluster work merged
before them left the open PR red on style, static analysis and PHPUnit alike.
None of it was visible here: the bundled PHP has no pdo_sqlite, so every
DB-backed test errored out before it could fail. The system PHP with
php8.1-sqlite3 reproduces CI exactly.
Style: two docblocks had been stacked by an insertion that landed between an
existing one and its function — `update()` in NodeRegistry and
`recordingComplete()` in ClusterApi get theirs back, with the newer method
moved below. Plus a doubled blank line, an import the ruleset counts as
unused (the reference stays, fully qualified, in the docblock), and a
multi-line `if` whose first condition belongs on the `if (` line, as
PEAR.ControlStructures.MultiLineCondition has it everywhere else.
PHPStan: `StreamProcess::startMovie()` returns the pid it read from the pid
file, or false. Declaring that instead of `mixed` is what QueueCommand's
`array<int, int>` was missing. And ScannerCommand's `$db` comes from
`self::db()`, never null, so its two `$db &&` guards were dead.
Tests: three fixtures build `cluster_nodes` from migration 029 and ALTER in
what later migrations added — they never gained `arch` (048) or `features`
(041). Three assertions had gone stale: the service command delegates to the
boot script now instead of holding a second copy of it, the policy carries
`heartbeat_sec`, and `cron:users` is a legacy job, so a node in mode 2 does
not run it.
phpcs, PHPStan 2.2.15 and `make gates` clean; suite 2106 tests, no failures.
A module declares SourceDriverInterface classes in module.json
("source_drivers", optional "start_timeout") and claims URL schemes of its
own; core runs the driver's engine in ffmpeg's place for those sources.
- SourceDriverRegistry: scheme -> driver, loaded via ModuleLoader::loadAll()
(no bootAll, so the streaming entry point works), core schemes reserved,
a scheme claimed by two modules goes to neither, unknown schemes refused.
- StreamProcess: per-source choice in the supervised spec and the PHP
monitor path; ffmpeg-only settings (transcode, maps, RTMP, delay…) refuse
the driver; argv escaped by core, must name the playlist; no fallback_cmd
or probe_cmd; the driver's start_timeout extends the start wait.
- ProcessManager recognises a driver engine by binary + playlist, so
monitor, stop and cron treat it as the stream's producer.
- MonitorCommand priority/force checks go through sourceAnswers()
(driver's available() or ffprobe).
- Offset the added branches in startStream/buildSupervisorSpec/producerKind
with behaviour-preserving simplifications (CRAP ratchet).
queue_enqueue, queue_claim and queue_update were named in
ClusterPool::INGEST_OPS and nowhere else: no handler on MAIN, no caller on a
node. `queue` is MAIN's table, so a node booted from its replica looped its
queue daemon on a database it must not dial, and cron:vod skipped queueing
altogether. A node in mode 2 encoded nothing and built no created channel.
QueueSink (Core, so it ships to LBs) is the node's half: with the CONTENT
flow on it asks MAIN through the agent, otherwise it runs the SQL its callers
ran, and in mode 2 without the flow it reports that the work was not queued
instead of reaching for a database. Only the node's own rows go through the
agent — MAIN keys every op to the caller — so admin code queueing onto
another server stays a database write.
NodeQueue serves the three ops on MAIN, every statement keyed to the
authenticated node's server_id and the claim capped at 200 rows, and the
handler denies them unless the node's CONTENT flow is on.
The daemon now claims each kind once a pass and reports what it started in
one update, instead of a statement per row; its loop also stopped falling out
after a single pass and re-executing itself, which the whole body sitting
inside the first query's `if` had done since the command was written.
- Root's crontab had two writers: `startup` installed cron:root_signals,
cluster:root, cron:root_mysql and the module licences, and `status`
then rewrote the crontab from an older list, filtering every `# XC_VM`
line — deleting cluster:root and cron:module_licenses. A node stopped
draining MAIN's signed root commands until its unit next started, and
the dashboard tells admins to run `status` by hand. `status` calls the
same writer now.
- update_binaries went to every server, and the DELETE that clears old
signals ran once per server inside the loop. `binaries` needs
bin/install/update_binaries.sh, stripped from the LB build, so every LB
answered "Updater script not found" and logged that it was updating.
- The LB update broadcast's liveness test was dead (`|| !is_main` is true
for every other row), so disabled and long-dead LBs were queued too.
- update_data is one row for the cluster; whichever server finished an
update cleared MAIN's pending record.
- The panel-log upload was gated on mode 2 instead of MAIN, so an LB took
the cluster-wide logs and marked them sent.
- cron:cache_engine was started on every boot of every node, answering
"Unknown command" on LBs, which never have cache_complete either.
- api_probe asked loopback parents — the node itself, mid-analysis — for
codecs over an unauthenticated endpoint.
The monitor, proxy producer, delay, archive, thumbnail and created
channel workers, the loopback start, cron:streams, cron:vod, the
on-demand daemon, the recorder, the RTMP callback and the relay
endpoints (/admin/live, /admin/timeshift, /admin/vod) took a stream's
definition and its runtime state together from one joined row of
MAIN's database; cron:cleanup skipped its checks in mode 2.
StreamSource::local() holds once the replica owns the streams (the R2
stream caches) and the node's own store is seeded. Then those readers
answer from the caches and the store, in the shape MAIN's row had, and
never read MAIN's database: StreamSource::nodeRow(), workerRow(),
plainRow(), createdRow(), builtServerRow(), channelRow(), movieRow()
and joined(), and the lists in the new Domain/Stream/NodeStreams. Each
runs the statement it ran otherwise, moved verbatim, so MAIN and mode 0
ask MAIN's database exactly what they asked. streamRow() and serverRow()
now carry the store's runtime state once seeded, so a stream restarts
from its current source and a created channel finds its cc_info.
cron:cleanup's stream, archive, VOD and created-channel checks run from
the whole R2 section (ReplicaStreams) and the store wherever local()
holds, mode 2 included, skip a check whose list is not whole, and prune
the store to what the node holds. The stream caches' index records each
stream's server_stream_id, so an update by that id finds its stream.
What the node cannot know stays conservative: the servers relaying a
stream are its configured children, and with CONNECTIONS on a stream's
viewers come from the agent's registry (an unanswered stream counts as
watched). The daemons reconnect MAIN's database only where the replica
does not answer, cron:vod queues no channel on MAIN's queue in mode 2,
and neither the on-demand daemon nor updateStreams() sends MAIN a cache
signal with STREAMS on.
Replace null checks on typed handles with instanceof, test arrays against
[] instead of truthiness, drop casts already guaranteed by the type
(validated etag strings, int stream ids, CLI string args), drop redundant
= null defaults, and make DbAllowlist::isLoopback an instance method.
Expand BlocklistChanges' array_push into direct appends.
MAIN applies a node's P0 `stream.monitor {stream_id, state}` events —
the fanout's view of a stream it supervises, as the node's agent follows
it — with the same pure rule the node's reconcile uses
(StreamProcess::supervisedRowUpdate, now public) against its own row;
a row the panel stopped is left alone, the cache is refreshed on change.
NodeFlows reads the agent's `features` from flows.json; with
"fanout_events" on a STREAMS node, reconcileSupervised stops writing
that state (the agent's feed carries it) and only releases streams.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
ContentSink routes what a node reports about its own content: recording
status (CONTENT), archive/thumbnail worker pids (STREAMS) and movie
ffprobe results (CONTENT). Legacy nodes write MAIN's database as before;
nodes with the flow on spool P0 events, which EventIngest applies only
where the node is the owner (recordings.source_id, <worker>_server_id,
a movie it holds), merging analysis keys into MAIN's movie_properties.
MAIN writes the stream cache signal when an event changes routing
state; a STREAMS node no longer writes it into MAIN's database.
RecordingFinalizer turns a recording into exactly one VOD in two steps
around the node's conversion: create() (idempotent, returns the id that
names the file) and finish() (attach to the node, status 2). A CONTENT
node gets the id from the new `recording_complete` op through the
agent's local socket (Core\Cluster\AgentClient), then reports status 2
as an event. The Cluster Nodes page switches CONTENT.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
Three Phase 0 seams, each with the current database behaviour as its only
backend:
- StreamSource: the stream row, this node's streams_servers row and the
stream options a node reads before running a stream. The launcher
(live, VOD and LLOD starts), MonitorCommand, ProxyCommand, ScannerCommand
and live.php's fanout proxy hand-off ran these queries themselves.
- StreamRowMerge: MAIN's side of a node's runtime state. StreamStateWriter's
SQL backend now calls it; later it merges `stream.state` events keyed by
the reporting node. eventFields() keeps only runtime columns and redacts
the source URL.
- StreamCacheBuilder: the stream_<id> cache entry (columns, server rows,
the direct-source rule), extracted from cron:cache_engine so the R2
stream delta can build the same shape.
OndemandCommand's stop path also still wrote streams_servers and signals
with raw SQL. It now uses StreamStateWriter and SignalDispatcher, like the
rest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
With fanout switched off (FanoutMode::legacyDelivery(), which is also true
when the licence denies fanout), live delivery goes back to the paths that
ADR 0003 Phase E removed. Each is restored from git history:
- HLS (E3): live.php serves HLSGenerator::generateHLS() over the on-disk
playlist. segment.php serves on-disk LIVE segments again through
X-Accel /xc_hls/, AES-128-encrypting on first read, and burns a pending
send-message signal with SignalSender. generateHLS now mints tokens with
Encryption::mintToken (so secure_stream_tokens applies), rewrites one
segment line at a time (a str_replace of 1_1.ts also hit 11_1.ts), and
tokenizes an fMP4 init map.
- TS (E2): live.php chase-reads the on-disk segments again (SegmentReader
and its test restored). $rSegmentSettings, which the old loop read but
never defined, is now set there. A signalled segment is no longer sent
twice.
- Proxy streams (E1): StreamProcess::startProxy() starts ProxyCommand
(XC_VMProxy[<id>]), and live.php relays its datagrams from the viewer's
unix socket. Fixed while restoring:
- the read loop ended after its first read (a stray break);
- it spun on an idle source;
- it never exited when the last viewer left;
- it passed the proxy argument's whole row to ffmpeg;
- it ran ffmpeg with no input when no source opened.
Runtime state goes through StreamStateWriter.
With fanout on, nothing changes: a daemon that is merely down still gives
not-on-air until the keepalive restarts it, and segment.php still 404s
non-daemon live segments.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
A node records the runtime state of the streams it runs (pids, status,
probe results, progress, current source) by updating its streams_servers
row; ~30 places did that with their own UPDATE. They now call
StreamStateWriter::update(stream, server, fields) or updateRow(id, fields),
which accept only the runtime-state columns (STATE_FIELDS) and write
through a sink. The SQL sink issues the same UPDATE as before
(StreamStateWriterTest); the cluster API plan (Phase 5) adds one that sends
a stream.state event for MAIN to merge instead.
Desired state (what runs where, parents, on-demand, the panel's edits)
stays MAIN's and is untouched. Phase 0 seam.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
47 call sites wrote `signals` rows directly, in three shapes: a viewer
kill (pid, rtmp), a root action for cron:root_signals (custom_data.action)
and a cache job (cache = 1, custom_data.type). They now name the intent -
SignalDispatcher::kill / rootAction / cache / cacheBatch - and the
dispatcher writes through a SignalSink. The only sink is LegacySqlSignalSink,
which writes the same rows; the cluster API plan (Phase 4) adds the sink
that turns them into signed commands for API-mode nodes. Phase 0 seam.
Behaviour kept column for column (SignalDispatcherParityTest), with two
deliberate differences:
- Actions the panel wrote as literal JSON with a space ('{"action":
"update_binaries"}', restart_services, reboot) are json_encode()d like
every other one. StatusCommand deletes update_binaries rows by their
json_encode() form, so the panel-queued ones were never cleaned up.
- kill() writes nothing for a pid <= 0: the signals daemon skipped those
kills, but for RTMP it sent a drop for client 0.
The "insert unless an identical cache signal is pending" check that
LineService and StreamProcess repeated is cache(..., once: true).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
PSR-12 wants it spelled out; phpcs reported all nine under
PSR12.Properties.ConstantVisibility.
All are `public`, which is exactly PHP's implicit default, so nothing changes at
runtime. Narrowing any of them would have been an API change wearing a lint fix as
a disguise: eight are referenced from outside their class (XC_Bootstrap::CONTEXT_ADMIN
ten times), and the CONTEXT_* four are explicitly @deprecated back-compat surface
for module code, so their whole purpose is to stay reachable.
One oddity left alone: StreamProcess::REMUX_EXIT_UNSUPPORTED has no references at
all, inside the class or out. It documents an exit status of `xc_fanout remux`
(supervisor.ExitUnsupported), so it reads as a protocol constant rather than debris
— worth confirming against the daemon before anyone deletes it.
Verified: 857 tests, make gates, make cs.
Adds LicenseGate::fanoutUsable() — the single "should this request use the
fanout daemon?" check (control socket present AND licence permits it). The two
fanout choke points now consult it instead of bare file_exists(FANOUT_CTL_SOCK):
StreamProcess::supervisionEnabled() and the three delivery tests in
Public/stream/live.php. When it returns false, live streams fall back to the
legacy delivery path automatically (soft enforcement) — nothing is hard-blocked.
The licence verdict itself (HWID-bound key tied to install_id, hidden
method-selector bytes, weekly re-validation, 7-day offline grace) lives in the
compiled xcvm_core extension; PHP only reads it via \XC_VM::license_valid()
when present. Fail-open where the extension or method is absent (dev/CI and
installs without xcvm_core), so this is behaviour-preserving until the C side
ships the verdict — and never bricks a working panel.
Helper kept in the new LicenseGate (not FanoutClient) to avoid touching that
debt-heavy file. supervisionEnabled() simplifies (cc 4->2). README disclosure
for activation will land when the verdict goes live.
Tests: fail-open without extension, unusable without control socket. Full
suite (816) + gates + CRAP + phpcs green.
Semgrep (php.lang.security.exec-use) blocked stopMovie: it killed the
encode with a `kill -9 $(ps | grep -F <path>)` pipeline and deleted the
files with `rm <path>.*`, both built from strings. The inputs were an
int id and a constant path, but nothing in the code proved that to a
scanner, and one careless edit would have made it injectable.
Find the encodes through /proc with ProcessManager::findProcessPIDs and
kill them with ProcessManager::kill; delete the files with glob() and
unlink(). Behaviour is unchanged: the trailing dot still keeps movie 5
from matching movie 50, and a no-transcode movie's symlink is removed
without touching its source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0135TSeD5tJGsD9peTZskAVM
- stopMovie killed every process matching grep '/<id>.' — a regex, where
the dot matches any character, so stopping movie 5 also killed the
running encodes of movies 50-59, 500-599 and so on. It now matches this
movie's output path as a fixed string.
- TmdbCron wrote a TMDb answer back without checking it. The client returns
an empty object on errors (rate limit, 404), so a failed movie refresh set
the name, year and metadata to NULL, and the NULL name then broke
player_api; a failed series refresh threw on getSeriesTrailer(int) and
stopped the run. Both now mark the refresh as not matched and keep the
stored data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
- Admin global search: SearchAjaxController asks CategoryService::getAllByType
(null) for every category type, as the method's docblock and body allow,
but the cs-fix pass typed the parameter `string`. Any search with results
threw and returned nothing.
- Movie and episode encoding: startMovie hands movie_subtitles to
buildSubtitleImport(string), and titles saved without subtitles store NULL.
The TypeError ended the queue daemon, which stopped encoding the whole
movie queue.
- Line authentication: every category save or reorder deleted the
category_map cache, which only the heavy cache pass rebuilds, up to five
minutes later. Meanwhile getUserInfo passed igbinary_unserialize(false) to
resolveCategoryIds(array) and player_api, the Ministra portal and probe
failed for every line. Category saves no longer delete the map (which
categories a bouquet reaches does not depend on a category's name, type or
order), and a missing map now means no categories instead of an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
Another `make rector-fix` pass over the same trees converged further: dead-code
removal and empty-if/else collapses across 36 files (CLI commands/cronjobs, VOD
and stream services, bootstraps). Reviewed against the full suite + PHPStan + a
targeted scan; committed as the corrected result.
- FIX (Rector bug, recurring): the boolean inversion again dropped the parens
around an assignment-in-condition — restored in GroupService
(removeGroupsFromUsers). It also hit RadioService, which was REVERTED wholesale
(see below), so no fix is carried for it.
- REVERT: src/Domain/Stream/RadioService.php is left unchanged. Rector both
introduced the dropped-parens bug there and altered control flow so a
previously-reachable `return null` became unreachable — a behaviour change in
an untested method that isn't worth verifying by hand.
- VERIFIED SAFE: RemoveUnusedPrivateMethodParameterRector dropped genuinely
unused params — TmdbCron::processSeries (an unused by-ref &$updateSeries, not
the one processEpisode uses) and ResellerTableRenderer::handleActiveCodes
($rIsAPI/$rPermissions); both sole call sites were updated.
Verified: PHPStan level 5 clean (0), suite 691 tests / 0 errors.
NOTE: the inversion rule still deterministically reproduces the dropped-parens
bug — do not re-run `make rector-fix` without skipping it first.
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.
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