Commit Graph
97 Commits
Author SHA1 Message Date
Divarion_D 2d00a7c74a fix(stream): keep an idle on-demand stream's stream_info
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
2026-10-11 14:23:58 +03:00
obscuremindandClaude Opus 5.5 aba0a1e06c fix(streams): quote subtitle names and list paths, stop a deleted recording
- 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>
2026-10-10 14:51:41 +00:00
obscuremindandClaude Opus 5.5 da3a0f3116 fix(streams): say in the stream's log when the remuxer's source goes on argv
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
2026-10-10 08:22:19 +00:00
obscuremindandClaude Opus 5.5 4deabd8647 fix(streams): decide -source_file on the daemon the start just asked
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
2026-10-10 08:21:12 +00:00
obscuremindandClaude Opus 5.5 3188db3f5c feat(streams): an Always Use ffmpeg switch per stream
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>
2026-10-09 15:27:15 +00:00
obscuremindandClaude Opus 5.5 ebb6df04df feat(streams): keep the native remuxer's source credentials out of argv
`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>
2026-10-09 15:27:07 +00:00
obscuremind e66b2bcbd4 fix(streams): pass stream options to ffmpeg as typed and fill each command in one pass 2026-10-06 00:41:59 +00:00
Divarion_D c588290c71 fix(vod): read subtitles from shared mounts instead of over HTTP
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
2026-10-05 15:20:35 +03:00
obscuremind cf7f5ae7a2 style: phpcs fixes and the ffprobe cache key's semgrep note
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.
2026-10-02 12:53:45 +00:00
Ricardo Santos e49af19417 fix(cache): atomic cache writes and safer stream/line/series regeneration
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 '..'.
2026-10-02 11:59:17 +00:00
Divarion_D f651b4cfe8 fix(streams): resolve and fetch YouTube sources through the stream's proxy
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).
2026-10-02 11:05:33 +03:00
obscuremind 489ebee80b feat(ffmpeg): every node fetches its distribution's builds from XC_VM_FFMPEG
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.
2026-10-01 11:09:48 +00:00
obscuremind 5435580395 feat(cluster): a node's own loopback carries no fleet secret
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.
2026-09-30 21:01:37 +00:00
obscuremind 344d49b15b feat(cluster): typed starts where the node runs them; nothing starts under a fence
- 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.
2026-09-30 20:53:41 +00:00
obscuremind 995f70d5dc feat(cluster): a fenced node releases its producers
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.
2026-09-29 14:34:17 +00:00
Claude be87b8c273 feat(cluster): the data plane without bearer credentials (Phase 8 relay half)
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
2026-09-28 20:45:21 +00:00
obscuremindandGitHub 10eb702962 Merge branch 'main' into obscuremind/gracious-lamport-fgd436 2026-09-28 20:05:35 +01:00
Claude 573d7120fc fix(streaming): fanout_enabled=0 fully restores the pre-fanout path
- 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
2026-09-28 19:02:32 +00:00
Divarion_D 6619b95799 feat(streams): driver sources answer source checks and supervisor probes
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.
2026-09-28 19:55:24 +03:00
obscuremind 7e02443b39 fix(ci): what phpcs, PHPStan and the test database caught on main
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.
2026-09-27 19:58:09 +00:00
DanilandGitHub c7e46efdbe Merge pull request #216 from Vateron-Media/feat/source-drivers
feat(streams): source drivers — module-owned live sources
2026-09-27 22:03:58 +03:00
Divarion_D ac2ad9fd6c feat(streams): source drivers — module-owned live sources with their own producer
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).
2026-09-27 21:45:44 +03:00
obscuremind ccd0a1c4c7 feat(cluster): serve a node's encoding queue over the API
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.
2026-09-27 14:17:08 +00:00
obscuremind e613329760 fix(cluster): stop fleet-wide jobs treating every node as MAIN
- 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.
2026-09-27 13:53:54 +00:00
obscuremind 52e7cbf21f feat(cluster): read this node's streams from its replica and store
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.
2026-09-27 11:47:38 +00:00
Divarion_D 4fedb3a325 refactor: apply Rector type-narrowing cleanups
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.
2026-09-26 17:48:21 +03:00
Claude 92936648d9 feat(cluster): fanout monitor feed as stream.monitor events (Phase 5, increment 3)
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
2026-09-25 15:41:08 +00:00
Claude aacaa67abc feat(cluster): content as events, recordings through MAIN (Phase 5, increment 2)
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
2026-09-25 15:28:11 +00:00
Claude c1793426de refactor(streams): add StreamSource, StreamRowMerge and StreamCacheBuilder seams
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
2026-09-25 10:07:47 +00:00
Claude 60b471d540 feat(streaming): serve live streams the pre-fanout way when fanout is off
With fanout switched off (FanoutMode::legacyDelivery(), which is also true
when the licence denies fanout), live delivery goes back to the paths that
ADR 0003 Phase E removed. Each is restored from git history:

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
2026-09-25 10:07:47 +00:00
Claude 5b924f4fac refactor(streams): write node runtime state through StreamStateWriter
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
2026-09-25 10:07:46 +00:00
Claude 82455d0d16 refactor(signals): route every signals write through SignalDispatcher
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
2026-09-25 09:13:51 +00:00
Divarion_D be7e0c13e2 style: declare visibility on the nine remaining class constants
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.
2026-09-22 18:20:27 +03:00
Divarion_D 5ef12b1cde feat(license): fanout licence gate seam (soft, fail-open)
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.
2026-09-18 15:32:50 +03:00
rootandClaude Opus 5 a50f42d955 fix(security): stop a movie without running shell commands
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
2026-09-17 08:41:26 +00:00
rootandClaude Opus 5 54480415a2 fix(vod): stopping a movie killed other encodes; failed TMDb lookups erased titles
- 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
2026-09-17 07:51:30 +00:00
rootandClaude Opus 5 62e7ea55cd fix: three crashes from null values and a missing cache file
- 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
2026-09-17 07:51:18 +00:00
Divarion_D 45488a4289 refactor: second Rector pass (reviewed) — more dead-code / simplification
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.
2026-09-13 17:42:31 +03:00
Divarion_D e23d4a66a8 fix(types): correct over-narrowed parameter types from the cs-fix auto-typing
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.
2026-09-13 16:31:14 +03:00
Divarion_D 758a9cab0b style: apply K&R + tab formatting across the codebase (make cs-fix)
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.
2026-09-13 14:14:23 +03:00
DanilandGitHub 0bde979972 Merge pull request #188 from obscuremind/main
Sync Repository
2026-09-13 12:37:24 +03:00
Divarion_D 71ccdac452 refactor: convert inline \XcVm\ FQNs to PSR-4 use imports
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.
2026-09-13 12:13:01 +03:00
rootandClaude Opus 5 b8eb1fa881 fix(live): viewers arriving together start a stopped on-demand stream once
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
2026-09-12 21:59:27 +00:00
obscuremindandClaude Opus 5 2b61e48ef5 fix(streaming): feed delayed, LLOD and loopback channels to the daemon
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>
2026-09-11 16:19:02 +01:00
obscuremindandGitHub 167adf3590 Update StreamProcess.php 2026-09-11 12:41:18 +01:00
rootandClaude Opus 5 2347cba1b8 fix(streams): a supervised stream must fill stream_info, not only the columns
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
2026-09-11 11:03:06 +00:00
rootandClaude Opus 5 02fba11819 fix(streams): the live type key is live, and the schema defaults are not refusals
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
2026-09-11 10:44:23 +00:00
rootandClaude Opus 5 269bee0148 feat(streams): say which producer runs a stream, and never hand remux to an old daemon
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
2026-09-11 10:30:07 +00:00
rootandClaude Opus 5 30eea7635c feat(streams): run live streams under the xc_fanout monitor, copy-only ones on its native remuxer
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
2026-09-11 09:20:03 +00:00
rootandClaude Opus 5 589f4f7ee2 Revert PR #1 (feat/daemon-monitor): panel-side encoder supervision
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
2026-09-11 09:19:54 +00:00