Commit Graph
58 Commits
Author SHA1 Message Date
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
obscuremindandClaude Opus 5 b37142698a fix(fanout): stop, force and rogue-kill must account for the daemon
Three places where the panel and the fanout daemon disagreed about who
owns a stream's encoder. The first is a showstopper; the second is a
self-inflicted outage; the third is silent.

* stopStream could not stop a supervised stream. It killed the encoder
  pid -- which is precisely the event the daemon's supervisor exists to
  react to, so the daemon started a replacement and the stream came
  straight back. It now releases supervision FIRST, so the daemon lets go
  and kills the encoder itself. (The existing FanoutClient::unregister
  call drops the ingest registration, which is a different thing.)

* The rogue-ffmpeg sweep in cron:streams kills every ffmpeg writing an
  .m3u8 that is not on its active-pid list. That list is built from the
  database pid, read at the top of the pass -- so an encoder the daemon
  restarted a moment later was not on it, and the sweep would shoot a
  perfectly healthy supervised stream and log it as a rogue. The daemon's
  own reported pid is now added to the list.

* force_stream wrote a `<id>.force` signal file, which only
  MonitorCommand ever read -- and it stands down for supervised streams.
  Forcing a source therefore did nothing at all. It now calls the daemon
  and falls back to the file when the daemon does not hold the stream or
  cannot be reached.

All three degrade to exactly the previous behaviour when the daemon is
unreachable.

Verified: php -l clean on the changed files and across all of src/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:45:06 +01:00
obscuremindandClaude Opus 5 8e24a2427a feat(fanout): make encoder supervision a real, settable option
Production-readiness pass on the panel side of daemon encoder supervision.

Until now the feature was gated on a setting that did not exist as a
column, so it could only be enabled by hand-editing the database and the
daemon never learned about it at all.

* Migration 018 adds `fanout_supervise`, off for every existing install.
* Admin -> Settings gains the toggle, alongside the other fanout tuning.
* SettingsService saves it (it is a checkbox, so it has to be in the
  boolean list or it can never be turned back off).
* FanoutConfig writes it into the daemon's config file as `supervise`,
  which is how the node learns it may supervise at all. Both halves must
  be on for anything to change, and either one off is a full rollback.
* StreamProcess reads `fanout_supervise` rather than the placeholder
  name, matching the panel's `fanout_*` convention.

Also: the recorded command file now says WHO ran the stream. `_.fanout`
when the daemon owns the process (the bare command it was handed) and
`_.ffmpeg` when this node ran it itself (with the redirect-and-background
tail). Two names rather than one because the first question in any
incident is which path the stream took, and a single filename cannot
answer it; the stale one is removed so a stream that switched paths does
not leave a lie behind.

FanoutConfigTest covers the new key both ways, including that a settings
array predating it reads as off -- that absence is the upgrade path for
every existing install.

Verified: php -l clean on every changed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:34:50 +01:00
obscuremindandClaude Opus 5 8244ece100 feat(fanout): health policy, source forcing and supervision reconcile
Completes the panel side of moving the per-stream watchdog into the
daemon (XC_VM_Fanout M5).

* daemonHealthPolicy() translates this stream's watchdog settings --
  seg_time, audio_restart_loss, fps_restart/fps_threshold/fps_delay and
  auto_restart -- into the daemon's health policy. These are the same
  conditions MonitorCommand.php checked in its inner loop; the daemon now
  judges them against the bytes it is already fanning out instead of by
  hashing a playlist, ffprobing a segment and reading a progress file.
  Each is omitted when the panel has it switched off.

* supervise() now sends adopt_match: the stream's own HLS path, which
  appears in every live command buildLive produces and in no other
  process. It is how the daemon recognises an encoder of ours that
  outlived it, so a daemon restart resumes watching the running ffmpeg
  rather than starting a second one beside it. Sending a token the panel
  knows, rather than letting the daemon guess, is what makes that safe
  against pid reuse.

* FanoutClient::supervisedIDs() reports what the daemon currently holds,
  so the panel can notice a restarted daemon and hand its streams back.
  Null (not an empty array) when unreachable, so a dead socket is not
  read as every stream needing re-registration.

* FanoutClient::forceSource() replaces writing a <id>.force signal file.

Still gated behind the `daemon_supervise` setting, absent on every
existing install.

Verified: php -l clean on the changed files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:14:48 +01:00
obscuremindandClaude Opus 5 56c0619e1e feat(fanout): hand live encoders to the daemon to supervise
Enables the daemon-side supervisor (XC_VM_Fanout M1, its ADR 0002): the
daemon starts, watches and restarts a stream's ffmpeg instead of a
resident PHP watchdog per channel doing it.

The daemon cannot read this panel's configuration -- database credentials
live inside the XC_VM C extension and are never handed out, not even to
PHP (Core/Config/ConfigReader.php) -- so the split is process ownership,
not config ownership. buildLive keeps composing the command here from the
database; the daemon only ever runs the command it is handed.

* buildLive no longer appends its own redirect-and-background tail. A
  backgrounded command leaves the daemon nothing to supervise: it has to
  be the process's parent to reap it and to signal it. The tail moves to
  liveRedirectTail(), which the legacy path appends itself, so that path
  is byte-for-byte what it was.

* startStream hands the bare command to the daemon when this node
  supervises there, passing the panel's own restart policy
  (stop_failures, stream_fail_sleep, on_demand, on_demand_failure_exit).
  The daemon writes the same <id>_.pid file, so stopStream,
  isStreamRunning and everything else downstream keep working untouched.

* FanoutClient gains supervise / releaseSupervision / monitorState /
  isSupervised over the existing control socket.

* MonitorCommand stands down when the daemon holds the stream. Two
  watchdogs on one ffmpeg fight -- each reads the other's kill as a
  failure and restarts -- so the channel would flap forever. The check
  asks the daemon rather than assuming, and an unreachable daemon answers
  false, so this watchdog keeps working whenever the daemon is not
  actually holding the process.

Off by default: daemonSupervises() reads the `daemon_supervise` setting,
absent on every existing install, so behaviour is unchanged until an
operator sets it. No migration, and clearing it is the rollback. Delay
streams are excluded for now (they write their own playlist).

Verified: php -l clean on the changed files and across all of src/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:15:17 +01:00
Divarion_D f0e387d238 fix(streaming): apply HTTP reconnect to all live sources, not just LLOD
Regular live HTTP(S) streams got no reconnect resilience — only the LLOD
path did. A flaky upstream that briefly drops the connection makes ffmpeg
exit ("Stream ends prematurely"), and the watchdog then kills and restarts
the whole stream, so marginal sources flap (start "not on the first try,
sometimes never"). Verified live: stream 560 (a slow XC_VM source) ran
ffmpeg at 0.5x with the HLS playlist stalling ~100s before each watchdog
restart, while a fast-CDN stream on the identical pipeline stayed up 21h.

Broaden the reconnect guard from `$rLLOD && ...` to every non-loopback
http(s) source: `-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5`.
Keep `-fflags +discardcorrupt` scoped to the on-demand (LLOD) path where it
originally shipped. Flags stay before `-i` (http-demuxer input options) and
are still guarded to http(s) — they are a fatal "Option not found" on
udp/rtmp/file inputs.
2026-08-26 18:26:48 +03:00
Divarion_D 6011caa7f2 refactor(domain): read settings via SettingsManager::get()
Replace SettingsManager::getAll()['key'] with SettingsManager::get('key') in the
domain layer (enable_cache, redis_handler, player_allow_hevc, download_images,
movie_year_append, save_restart_logs, mag_default_type, channel_number_type).
get() preserves the raw value so existing comparisons (== 1, != 0, != 'bouquet')
are unchanged, while adding a default to avoid undefined-key warnings.
2026-08-24 21:55:50 +03:00
Divarion_D b215d774ed refactor(streaming): drop dead XC_VMProxy[] process checks (ADR 0003 E, dead-code)
ProxyCommand was removed in E1, so no process is ever titled XC_VMProxy[<id>]
anymore — the process-name checks in ProcessManager::isMonitorAlive and
StreamProcess::stopStream that matched it can never be true. Remove the dead
references; the real titles (XC_VM[], LLOD[], Loopback[], the m3u8/ts patterns)
are untouched.

Not touched: the live.php non-proxy .ts chase-read (E2) and HLSGenerator
generateHLS / segment.php tmpfs-serving (E3) — those are the active
daemon-reachability fallback (daemon down, re-feed windows, and the
proxy/restreamer/llod2 types not yet canary-validated), so deleting them now
would remove the rollback. Gated on Phase D stability across all types.
2026-08-17 20:46:19 +03:00
Divarion_D 6a97159c75 refactor(streaming): P2/E1 — delete legacy proxy path (ProxyCommand)
Phase E (ADR 0003), step 1: proxy live streams are now daemon-only. The
daemon owns the proxy puller (registered + off-air-probed in live.php), so
the legacy producer + relay are dead code and are removed:

- delete Cli/Commands/ProxyCommand.php (the XC_VMProxy producer).
- remove StreamProcess::startProxy and ProcessManager::startProxy (only
  caller was live.php).
- live.php: drop the startProxy branch in the process block and the
  AF_UNIX socket relay in the proxy delivery arm. A proxy stream whose
  daemon is unreachable ($rFanout false) now shows not-on-air — the
  keepalive restarts the daemon in ~2s — instead of falling back to the
  legacy producer.

This removes the proxy fallback: the daemon is required for proxy delivery
(its own crash resilience is the keepalive). Non-proxy legacy stays for now.
PHPStan clean, 443 tests.

Refs ADR 0003 (Phase E).
2026-08-16 20:03:49 +03:00
Divarion_D 151c8e7bda feat(streaming): P2/B — encrypted HLS served from the daemon (Phase F prereq)
Close Phase B's unencrypted-only limitation: encrypted HLS is now served from
the daemon's in-RAM segmenter too, so encrypt_hls streams no longer need the
legacy tmpfs .enc path — one of the two prerequisites for dropping the
streaming tmpfs (Phase F).

- StreamProcess: generate the stream's HLS key/iv BEFORE registering the daemon
  ingest (moved writeStreamKeyIv up) and, when encrypt_hls is on, hand the hex
  key/iv to the daemon at FanoutClient::registerIngest so it encrypts the HLS
  segments it serves.
- FanoutClient::registerIngest: optional key/iv passed in the ingest PUT body.
- HLSGenerator::tokenizeDaemonPlaylist: prepend #EXT-X-KEY (URI /key/<token>,
  IV from <id>_.iv) for encrypted streams, matching generateHLS.
- live.php m3u8: drop the !encrypt_hls gate — daemon HLS now covers both.
- segment.php unchanged: the daemon-segment X-Accel already returns the
  daemon's (now-encrypted) bytes.

Box-validated: encrypt_hls=1 → 569.m3u8 has #EXT-X-KEY + tokenized daemon
segments; a segment decrypts (stream key + playlist IV) to a valid TS. Daemon
crypto is byte-identical to openssl. PHPStan clean, 443 tests. Daemon 0.6.0.

Refs ADR 0003 (Phase B encrypted). Daemon: XC_VM_Binaries 0.6.0.
2026-08-16 19:52:07 +03:00
Divarion_D 070d89b3c4 feat(streaming): P2/A2 — tee non-proxy live ffmpeg into the xc_fanout daemon
Bring non-proxy live streams into the daemon (ADR 0003, Phase A). Before
launching a standard live stream's ffmpeg, StreamProcess registers a daemon
ingest (FanoutClient::registerIngest → PUT /ingest/<id>, returns the socket)
and, when the daemon accepted it, builds the HLS output through the `tee`
muxer so one ffmpeg feeds BOTH the existing on-disk HLS and the daemon:

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

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

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

Refs ADR 0003 (Phase A).
2026-08-16 18:16:45 +03:00
404errordeveloper 51a3e399a4 Implemented dynamic symlink directory setter with dropdown in settings menu. Rather than the hardcoded path in previous commit, this one the user will be able to set themseleves 2026-08-15 17:46:47 +02:00
404errordeveloper e1e5a66609 Fixed issue with symlink not working on LB. When Symlink was enabled for a LB server it would still encode the content with ffmpeg, copying over the entire file and filling storage. Symlink worked fine on MAIN, just not on LB. This fix does include a hardcoded path at like 338 '', which would be the same mount that the Watch Folder is designated to on that LB 2026-08-15 00:39:44 +02:00
Divarion_D 4022993857 refactor(stream): StreamProcess P2/P3 tidy — dead/stale docs, dedupe update signal
- Remove an orphaned docblock (it described createChannelItem but sat above
  buildSubtitleImport, left behind by an earlier extraction).
- Refresh buildLive()'s docblock: it is now the sole command builder, not a
  shadow — the "run in shadow before it replaces the inline assembly" note was
  stale after the flip (0bd6a9dc).
- deleteCache(): if(!empty){…}else{return} -> early-return guard clause.
- updateStream()/updateStreams(): extract the identical check-then-insert of the
  cache-invalidation signal into insertCacheSignalOnce(); also drops the
  redundant double return in updateStreams and the unconditional getMainID()
  when caching is off.

Documentation/cosmetic + a behaviour-preserving dedupe. Refs: #148

Verified: php -l, make phpstan (No errors), make gates. Serve check on the hot
updateStream path (warm 567 -> m3u8 200/#EXTM3U).
2026-08-09 18:56:00 +03:00
Divarion_D 0bd6a9dc8d refactor(stream): flip startStream to buildLive(), remove inline assembly + shadow
Completes the in-progress FFmpeg-command migration. startStream's ~180-line
inline command assembly (and the shadow buildLive() diff-check that ran beside
it) are gone; the command is now built solely by the pure buildLive(), fed the
raw stream row. The delay-playlist bookkeeping (segment start + sleep window)
is hoisted just above the call since buildLive() consumes segmentStart /
delayActive. Net -171 lines.

buildLive() already did all transcode-attribute resolution and {TEMPLATE}
substitution internally, so feeding it the raw row (instead of the
post-mutation row the shadow used) removes a latent double-mutation in the
custom-transcode branch (transcode_profile_id == -1): json_decode((string)
$array) on an already-decoded value would have yielded [] there. Only the
inline path ever ran, so this was never a live bug — but it made the shadow an
invalid equivalence probe for that branch, which is why the flip is verified
directly rather than via the shadow log.

Equivalence verified two ways:
- Structural: the 129 command-building statements of buildLive() are
  line-for-line identical to the inline block (the sole non-command delta was a
  dead `$rFFPROBE = FFPROBE_BIN_40` assignment that never entered the string).
- Empirical: on a live box, the generated _.ffmpeg is byte-identical
  (md5-equal) before vs after the flip for BOTH a plain stream and a
  custom-transcode stream (enable_transcode=1, profile_id=-1, custom attrs),
  and the flipped stream serves (m3u8 200/#EXTM3U, ts 200 ~2.8MB).

Refs: #148

Verified: php -l, make phpstan (No errors), make gates.
2026-08-09 18:46:43 +03:00
Divarion_D 4644a0780c refactor(stream): dedupe stop-path PID lookup + row reset helpers
- pidFromFileOrColumn() replaces stopStream's two identical "read the PID from
  the sidecar file, else fall back to the streams_servers column" blocks (the
  monitor_pid and pid lookups). stopStream no longer needs its own $db handle.
- resetStreamServerRow() replaces the 11/12-column NULL-reset UPDATE duplicated
  in stopStream (with monitor_pid) and stopMovie (without) — the difference is
  now a $rWithMonitor flag.

Pure mechanical extraction, behaviour preserved. Refs: #148

Verified: php -l, make phpstan (No errors), make gates. Canaried live: warmed
stream 567 (pid+monitor running, sidecars present) then StreamProcess::stopStream
(567, true) -> row reset to pid/monitor NULL + status 0 and all _* sidecars
removed.
2026-08-09 18:30:57 +03:00
Divarion_D fbc5a1de1c refactor(stream): dedupe launcher key/iv + pid-segment cleanup helpers
Two byte-identical blocks were copied across the stream launchers:

- writeStreamKeyIv()       — the AES-128-CBC _.key/_.iv sidecar generation,
                             repeated verbatim in startStream, startLoopback and
                             startLLOD (3x)
- clearStreamPidSegments() — the "rm _*.ts + unlink stale _.pid" preamble,
                             repeated in startLoopback and startLLOD (2x)

Pure mechanical extraction, no behaviour change. Refs: #148

Verified: php -l, make phpstan (No errors), make gates. Canaried live via a
warmed stream (567): startStream produces a 16-byte _.key and 16-byte _.iv and
the m3u8 serves (HTTP 200, #EXTM3U).
2026-08-09 18:27:47 +03:00
Divarion_D 444483d7b4 feat(streaming): add pure buildLive() assembler with shadow-diff (no flip)
Phase 1 of extracting the live ffmpeg command assembly out of startStream.

buildLive(array $data): string is a byte-faithful copy of the inline assembly,
fed from a prepared $data array instead of loop-local state, with the delay
playlist I/O and segment-start/sleep left in startStream (arriving via
$data['segmentStart']/['delayActive']). The one non-verbatim change is the
ac3/eac3 dts_legacy bin switch, now a local reassignment instead of mutating the
$rFFMPEG_CPU/$rFFPROBE globals (the $rFFPROBE write was already dead). Homed on
StreamProcess for now so the private output/logo/aac helpers resolve via self::;
a FFmpegCommand facade is a follow-up.

startStream computes buildLive() alongside the inline assembly and error_log()s
any divergence, but still executes the inline $rFFMPEG -- shadow mode, no flip.
Once the diff log stays empty on real traffic the call site can switch over.

Characterisation tests cover simple/custom_ffmpeg/loopback/delay/rtmp branches
and assert no unresolved {TOKEN} survives.

Verified: phpstan level 5 green, phpunit 397/397, make gates green.
2026-08-07 21:50:35 +03:00
Divarion_D d5205f82d3 refactor(streaming): separate delay-playlist I/O from command assembly in startStream
Preparation for extracting the ffmpeg command assembly into a pure builder: the
delayed-HLS branch interleaved file I/O (reading the delay playlist, the
short-playlist abort, merging the old playlist) with string building. Hoist the
I/O and the segment-start/sleep computation ahead of the output assembly, behind
an explicit $rDelayActive flag, leaving the two output-append branches as pure
string building that consumes the precomputed $rSegmentStart.

Behaviour is identical (the I/O ran in the same delay-only case before, just
lower down); this only untangles side effects from string assembly so the
assembly can move to a testable, shadow-diffable FFmpegCommand::buildLive.

Verified: phpstan level 5 green, phpunit 391/391, make gates green.
2026-08-07 21:40:20 +03:00
Divarion_D 2364aae6ee refactor(streaming): extract delay resume helpers from startStream
The delayed-HLS branch inline-computed two things: the resume segment number
parsed from the existing delay playlist (last-or-previous line's _<n>.ts index),
and the delay sleep seconds (delay_minutes*60 reduced ~10s per already-produced
segment, floored at 0). Pull both into pure resolveDelaySegmentStart() and
resolveDelaySleepTime() helpers with unit tests. The playlist merge and the
short-playlist abort stay inline. No behaviour change.

Verified: phpstan level 5 green, phpunit 391/391, make gates green.
2026-08-07 21:01:59 +03:00
Divarion_D 39090e37ba refactor(streaming): extract hasSkipFFProbe and skipFFProbeOutput from startStream
The ffprobe-skip branch of the source loop hand-rolled two things: a scan of the
stream arguments for skip_ffprobe == 1, and the literal assumed h264/aac mpegts
result used when the scan matched. Give both names via pure helpers and cover
them with unit tests. No behaviour change.

Verified: phpstan level 5 green, phpunit 385/385, make gates green.
2026-08-07 20:54:14 +03:00
Divarion_D ececd4aa8f fix(streaming): read the ffprobe cache under the same key it is written
The 300s ffprobe cache is written, existence-checked and deleted under
md5($rSource), but the content read used md5($rStreamSource)
(= parseStreamURL($rSource)). When parseStreamURL rewrote the source the read
fetched a different, usually missing file, so igbinary_unserialize(false)
failed the validity check and every start re-probed instead of hitting the
cache. Read under md5($rSource) so the key matches the write.

Verified: phpstan level 5 green, phpunit 382/382, make gates green.
2026-08-07 20:51:55 +03:00
Divarion_D 2d885af262 refactor(streaming): extract aacBitstreamFilter from startStream
The {AAC_FILTER} substitution carried the rule inline: emit -bsf:a
aac_adtstoasc only when a copied AAC audio stream is muxed into a non-FLV
container. Give that rule a name and a test via a pure aacBitstreamFilter()
helper. No behaviour change.

Add unit tests: applied for copied AAC in mpegts/empty container, skipped for
FLV, skipped when the codec is not AAC or the output is not a copy.

Verified: phpstan level 5 green, phpunit 382/382, make gates green.
2026-08-07 20:46:10 +03:00
Divarion_D f99048b79f refactor(streaming): extract resolveStreamCodecMeta from startStream
The codec-metadata derivation persisted for a started live stream (player
compatibility, audio/video codec names, resolution snapped to the nearest
standard height) was an inline block reading ffprobe output. Pull it into a
pure resolveStreamCodecMeta() helper taking the ffprobe array and the
player_allow_hevc flag and returning [compatible, audio, video, resolution].

The only external input, SettingsManager::getAll()['player_allow_hevc'], is now
passed in (getAll() just returns the cached settings array, so hoisting the read
out of the guard is free). Add unit tests: non-array/missing-codecs defaults,
compatible h264/aac, resolution snapping, HEVC gating by the allow flag, and
incompatible-but-reported codecs.

Verified: phpstan level 5 green, phpunit 379/379, make gates green.
2026-08-07 20:43:37 +03:00
Divarion_D 8973f5580f refactor(streaming): extract appendHeaderArgument from startStream
The X-XC_VM-Detect / X-XC_VM-Prebuffer header injection was duplicated three
times in startStream: each block looped the argument list to append the header
to an existing 'headers' entry and added a new one if absent. Collapse the three
copies into a single appendHeaderArgument() helper and drop the shared
$rProcessed flag they threaded through (it leaked state between the blocks).

Behaviour is unchanged: the helper uses a local applied flag, and the three call
sites produce the same argument lists as before. Add unit tests covering
append-to-existing, create-when-absent, append-to-every-headers-entry, and the
empty-list case.

Verified: phpstan level 5 green, phpunit 373/373, make gates green.
2026-08-07 20:32:51 +03:00
Divarion_D b88ae60dc1 refactor(streaming): extract startStream probe/failover helpers and default its command variables
Continue breaking down StreamProcess::startStream:
  - extract resolveProbeSettings() (ffprobe/analysis timing) and
    rotateSourcesPastCurrent() (source-failover ordering) as private static
    helpers, with unit tests locking in their behaviour;
  - simplify 15 inverted empty-if/else blocks across the class into a single
    positive condition (no behaviour change).

Also fix the latent undefined-variable bugs the refactor surfaced under PHPStan:
startStream assembles the ffmpeg command from state produced inside the source
failover loop (which closes before the command is built) and inside the
non-custom_ffmpeg branch. On an empty source list, or when a custom ffmpeg line
is used, several variables were read while possibly undefined -- masked at
runtime only by ternary guards and suppressed in the PHPStan baseline. Default
them at the two scopes that own them:
  - loop-scoped, read after the loop: $rSource, $rRealSource, $rStreamSource,
    $rProtocol, $rFFProbeOutput
  - non-custom_ffmpeg-branch-scoped, read in the {MAP}/{GEN_PTS}/{READ_NATIVE}
    substitution and delay sleep: $rMap, $rGenPTS, $rReadNative, $rSleepTime
and drop the 9 now-obsolete entries from phpstan-baseline.neon.

Behaviour is unchanged on the happy path (the loop/branch overwrite the
defaults) and the defaults match the existing runtime ternary-guard results.

Verified: phpstan level 5 green (whole project, entries un-suppressed),
phpunit 369/369, make gates green.
2026-08-07 20:20:09 +03:00
Divarion_D 0f773b9758 refactor(streaming): extract map/scodec/source/HLS/FLV helpers with tests
Continue breaking up createChannelItem, startMovie and startStream by pulling
five self-contained blocks into pure, tested helpers:

- resolveOutputMap()          — VOD -map selection (custom > strip-subs > copy-all)
- subtitleCodecForContainer() — mp4->mov_text, mkv->srt, else copy
- resolveChannelSource()      — channel source string -> [serverId, path], rewriting
                                s:<server>:<path> to the remote getFile API URL
- buildHlsMpegtsOutput()      — HLS/mpegts segmenter output args
- buildFlvOutput()            — shared FLV output wrapper (RTMP relay + external
                                push, previously two identical lines)

The startStream extractions pass the conditional $rOptions/$rFLVOptions in as
parameters, so the control flow is unchanged. Behaviour preserved.

StreamProcessMovieOutputTest covers map precedence, container->codec mapping,
every source-resolution branch, HLS output assembly, and FLV wrapping.

Verified: php -l clean, make gates green, phpunit 360/360 green.
2026-08-07 19:31:18 +03:00
Divarion_D 0bb87452ad fix(streaming): import and map all VOD subtitles, not just the first
buildSubtitleImport nested the metadata loop inside the import loop and reused
the outer counter $i. The inner loop advanced $i to count(files), so the outer
import loop exited after its first iteration: only the first subtitle was
imported (-sub_charenc -i), while -map/-metadata was still emitted for every
file — mapping ffmpeg inputs that were never added. Multi-subtitle movies got a
single imported track plus dangling -map references (broken output).

Split the nested loop into two siblings — import every file, then map every
file — and cache the count. All configured subtitles are now imported and
mapped to the correct input index.

Add tests/Unit/StreamProcessSubtitleTest.php covering empty input, a single
local subtitle, remote-server fetch, and the multi-subtitle case that
reproduces the defect (import count was 1, must equal N). Verified red→green
against the pre-fix code.

Verified: php -l clean, make gates green, phpunit 336/336 green.
2026-08-07 19:04:16 +03:00
Divarion_D 4980b00829 refactor(streaming): extract buildSubtitleImport from startMovie
Move the VOD subtitle import + metadata assembly out of startMovie into a
private helper so the method is smaller and the subtitle logic is isolated.

Behaviour is preserved verbatim, INCLUDING a pre-existing bug: the inner
metadata loop reuses the outer import loop's $i counter, so the import loop
effectively runs once (first file) while metadata is mapped for all files.
Left as-is here; it is a real defect to fix in a dedicated change.

Verified: php -l clean, make gates green.
2026-08-07 18:56:00 +03:00
Divarion_D 7adf7a5b27 refactor(streaming): extract shared ffmpeg-command helpers in StreamProcess
createChannelItem, startMovie and startStream each inlined the same blocks
verbatim. Extract them into private helpers — removing the duplication and
shrinking the three oversized methods:

- buildLogoFilterOptions() — logo / -filter_complex overlay block (was inlined 3x)
- resolveGpuInputCodec()   — CUVID hardware-decoder detection (was inlined 2x)
- applyDefaultCopyCodecs() — default -acodec/-vcodec to stream copy (was inlined 3x)

Pure extraction, behaviour preserved. startStream's GPU block is intentionally
left inline: it reuses the already-computed $rFFProbeOutput instead of
re-probing the source, so it is not the same code.

Verified: php -l clean, make gates green (PSR-4 use-imports, LB manifest,
vendor prod-only). net -28 lines.
2026-08-07 18:51:07 +03:00
Divarion_D b98898b25e fix(streaming): reconnect LLOD http input to survive source drops 2026-08-07 16:54:08 +03:00
Divarion_D 22e422bb1c fix(streams): report real transcode speed instead of a stuck "1x"
The Stream Info column always showed a transcode speed of "1x" even though
ffmpeg's real speed fluctuates. Two independent bugs were involved.

1. Progress was never captured while a stream ran.
   ffmpeg's `-progress` posted to the HTTP /progress endpoint, but ffmpeg
   holds one open-ended chunked POST for the stream's whole life. PHP-FPM's
   php://input can't read a length-less body, and nginx only invokes PHP
   once the request body is complete, so the endpoint fired only at stream
   end. `progress_info` therefore stayed empty and the view fell back to the
   hardcoded "1x".

   Switch ffmpeg to write `-progress` to a local file (the mechanism the
   created-channel path already uses). StreamsCronJob now tails that file,
   keeps the last complete report block, re-encodes it as the JSON the panel
   expects, and truncates it each cycle so it stays tiny on disk.

2. Speeds just above 1 were swallowed by the display.
   `floor($speed * 100) / 100` dropped e.g. 1.01 to "1x" because 1.01 * 100
   is 100.9999… in floating point. Use round() instead, and apply the same
   robust parsing to the radios path (which multiplied the "1.01x" string).

Files:
- Domain/Stream/StreamProcess.php   ffmpeg -progress -> local file
- Cli/CronJobs/StreamsCronJob.php    tail/parse/truncate the progress file
- Public/Controllers/Admin/TableController.php  round() speed display
2026-08-01 23:35:05 +03:00
Divarion-D 3e4fbae04c fix(stream): null-safe activity, stream and server lookups
Null-coalesce absent keys in activity info, cached signal counts, streams_servers
maps, server protocol and PID-running results, and @-suppress the stale .pid
unlink. Avoids offset-on-null/undefined-key warnings under PHP 8 without changing
behaviour on present data.
2026-07-30 19:47:05 +03:00
Divarion-D 603ee3c8b6 fix(streams): repair created-channel build chain and add live encode progress
- QueueCommand: adopt a live build PID instead of unlinking its .create
  marker and relaunching (which killed the running encoder and reset the
  build to 0% on every queue re-add); widen the PID capture window from
  0.3s to 5s to survive console.php bootstrap
- VodCronJob: check build liveness locally via /proc cmdline instead of an
  HTTP self-call that falsely reported the build dead whenever the internal
  API or server_online flag hiccuped
- CreatedCommand: verbose CLI output (per-source status, encode % / speed
  every 10s), local process checks instead of HTTP, cleanup of stale
  _.create on the nothing-to-build early exit
- StreamProcess: pass -progress <id>_<md5>.progress to the channel-item
  ffmpeg command; CreatedCommand parses it and mirrors the percentage into
  streams_servers.progress_info
- TableController/api.php: "% DONE" now includes fractional progress of the
  source currently being encoded
- MonitorCommand: stop type-3 (created) channels from falling through into
  the LLOD branch after startStream — LLOD cannot consume raw
  "s:<server>:<path>" sources and spammed "[LLOD] Invalid source" in a
  restart loop; the on-demand LLOD shortcut now also skips type 3
- PHP 8 warning fixes along the same chain: missing on_demand key
  (ChannelService), undefined $rSplit and missing parent_id column in
  grouped queries (TableController), missing .create file (VodCronJob),
  empty lines in _.list parsing and null cc_info (StreamProcess)
2026-07-10 19:19:25 +03:00
Divarion-D 732a0cd1dc fix(streaming): guard probe URL port, transcode json_decode and playlist md5 races
- StreamProcess: parse_url() output has no 'port'/'path' keys for default-port
  URLs — probe URL built with isset() guards (477 warnings/8h in panel logs)
- StreamProcess: json_decode of transcode_attributes/profile_options can
  return null — normalize to array before array_key_exists/array_merge
  (TypeError at stream start)
- MonitorCommand: the .m3u8 playlist can vanish between the loop's
  file_exists check and md5_file (stream restart race) — guard both call sites
2026-07-08 12:17:54 +03:00
Divarion-D 57e4805769 refactor: remove unused Database imports across multiple services 2026-06-28 20:45:17 +03:00
Divarion-D 241fb685dc fix(StreamProcess): adjust FFMPEG command options for improved performance 2026-06-28 14:26:14 +03:00
Divarion-D a9cf7a5266 refactor(database): centralize domain DB access in DatabaseAware trait
Replace the fragile per-class setDb()/db() pattern (which threw when a
  bootstrap path forgot to wire $db) with a shared trait that lazily resolves
  from DatabaseFactory. Fixes the streaming UserRepository fatal and the same
  latent issue in module crons. 42 classes converted.
2026-06-28 13:48:35 +03:00
Divarion-D 7fa26585e0 fix(stream): add initial segment time for faster playback start 2026-06-26 19:52:54 +03:00
Divarion-D c2343885dc chore(phpstan): exclude vendored libs + fix safe return.void/deadCode errors
- Exclude bundled third-party code from analysis (Modules/tmdb/lib/*,
    Core/Util/MobileDetect.php) — like the vendored Composer packages, these
    are not our code (php-tmdb client, Mobile-Detect). Removes 67 baselined
    errors incl. the only class.notFound and phpDoc.parseError (both vendored).
  - return.void (10): drop the discarded return value (return true/false/null →
    return;) in void methods — NetworkUtils, LineService, StreamProcess, TMDbService.
  - deadCode.unreachable (2): remove the dead `return;` after AdminHelpers::goHome()
    (a never-returning call) in Plex/WatchController.
  - Regenerate phpstan-baseline.neon: 440 -> 361 errors (291 -> 241 blocks).

  Verified: make phpstan green, PHPUnit 298/298.
2026-06-26 19:26:18 +03:00