Discover the bundled FFmpeg builds from bin/ffmpeg_bin/<version>/ instead of a
hardcoded version list, and expose what each build can do.
- FfmpegBinaries (new): scans real version folders (skips *.backup.*), probes each
binary's hardware encoders (nvenc/qsv/vaapi/…), flags GPU capability, caches the
result to tmp/ffmpeg_capabilities.json keyed by a folder-mtime fingerprint. UI-only.
- FfmpegPaths::resolve() rewritten: builds paths dynamically from the version string
(is_file check, no shell — stays on the bootstrap hot path) and takes a separate GPU
version; the switch and the per-version constants are gone. Unknown CPU version falls
back to the legacy 4.0 build; an empty/invalid GPU version falls back to the CPU build.
- Wire the previously-dead ffmpeg_gpu column: LegacyInitializer passes it to resolve();
settings.php lists versions from the scanner and adds an ffmpeg_gpu <select> limited to
GPU-capable builds ("Same as CPU" = empty). No migration — the column already exists.
- Info tab: a per-build FFmpeg table (version, build banner, GPU badge, hw encoders).
- De-hardcode: drop the unused FFMPEG_BIN_71/80 + FFPROBE_BIN_71/80 constants; keep only
the 4.0 anchor (DTS override in StreamProcess + FfmpegPaths fallback). Llod/Loopback/
Record now use FfmpegPaths::cpu()/probe() (config-selected version) with the 4.0
constant as fallback, matching Scanner/Created/Thumbnail.
Make 'beta' the canonical update_channel value across the settings UI,
GitHubReleases (prerelease filter + cache-file suffix + setChannel), the
binary/fanout update commands, and the module channel mapping.
'unstable' is kept as a legacy alias so nothing breaks mid-upgrade:
GitHubReleases::normalizeChannel() maps it to 'beta', and the binary commands
still accept it. Migration 011 rewrites existing settings.update_channel
'unstable' -> 'beta', and the settings dropdown normalizes a stale 'unstable' to
show Beta selected before the migration runs. Docs (en) updated.
Display-only rename in the settings dropdown; the stored update_channel value
stays "unstable", so GitHubReleases, the binary/fanout update commands and the
module channel logic are untouched (they already treat "beta" as an alias of
"unstable"). No DB migration needed.
Refresh the vendored SQL browser (src/Public/Views/admin/database.php) from
phpMiniAdmin 1.9.240801 to 1.9.251125. XC_VM integration is preserved: the
AdminHelpers use-import, the DB_ACCESS_ENABLED gate + goHome(), the session-hash
and is_admin guards, and the empty $DBDEF defaults (connection credentials are
never hardcoded — supplied via the connect form / conn[] cookie).
FanoutConfig does a read-modify-write of the xc_fanout daemon's config.json: read
the current snapshot, overlay only the panel-owned keys (hls_target_sec from
seg_time; prebuffer_max_sec sized to cover the largest per-viewer prebuffer and
the HLS window, floored at 40 and capped at 120), and write it back atomically.
Keys the daemon owns are preserved untouched, so a key it later adds is never
clobbered. No-op when the daemon tree is absent on the node.
Wired into SettingsService::edit() after a successful settings UPDATE; never
throws, so a config write can't break a settings save.
StreamAjaxController start/stop/restart (stream + vod) echoed the raw return of
ApiClient::request(). That call relays to the local /admin/api handler, whose
body can come back empty under PHP-FPM (a self-request), blanking the page and
breaking the frontend's `data.result` check — even though the node side-effect
(start/stop) still fired. The refactor comment claimed this mirrored api.php, but
the legacy handler always echoed a fixed {"result":true} after getMultiCURL.
Fire-and-forget the relay and return {"result":true} via $this->ok(), matching
the legacy behaviour and ServerAjaxController::server(). Harden rtmp_kill the same
way: fall back to {"result":false} on an empty systemRequest body (offline node)
instead of echoing nothing.
`fanout_binary force` reinstalls the daemon but resolved the target version from
GitHubReleases' 30-minute on-disk cache, so a forced update could pin a stale
"latest" (e.g. an old v0.7.0) instead of rechecking GitHub. force now calls
clearCache() before getReleases(), so a forced reinstall always resolves against
a fresh response.
The supervisor could run in multiple copies (a boot/keepalive race, a manual
start), and a supervisor that died without its child left the daemon orphaned
and still holding the sockets — so several xc_fanout daemons fought over the
bind, each burning RAM and CPU.
run.sh now holds an exclusive flock (fd 9) for its whole lifetime: a second
run.sh fails to take it and exits immediately, so there is never more than one
supervisor — and since each supervisor blocks on exactly one daemon, never more
than one daemon. Before spawning, the loop pkills any stray xc_vm xc_fanout
(a daemon orphaned by a previous supervisor) so a fresh bind never races an
orphan. Start/exit lines are appended to the daemon log for diagnosis. The
RootSignalsCronJob comment is updated to reflect the flock guard.
Add a version selector to the docs and publish one snapshot per release instead
of a single rolling site, so readers can pick the docs matching their installed
version (the docs change release to release).
- mkdocs.yml: enable the Material version selector (extra.version.provider: mike,
alias: true).
- docs/requirements.txt: add mike==2.1.3.
- pages.yml: deploy with mike, triggered by a release TAG (semver) instead of
every push to main — publishing is tied to the release because docs/ru is only
regenerated then. Deploys `X.Y.Z` + the `latest` alias to the gh-pages branch
and sets latest as default. workflow_dispatch takes an explicit version.
- updates_checklist.md: note the tag-triggered versioned publish.
One-time setup (GitHub UI): Settings → Pages → Deploy from a branch → gh-pages.
Add a Developer Guide page covering the admin `?action=` JSON endpoints:
- The PSR-4 controllers under Admin\Ajax that replaced the retired ~4985-line
Views/admin/api.php (PR #173) — BaseAjaxController scaffolding
(ok/fail/gate/gateAny/requireXhr/json), LineStateTrait, per-area controllers,
route registration and the dispatchApi → AjaxController fallback order.
- The structured search JSON contract (PR #174): envelope, item shape,
self-describing actions, per-entity data, and the client-side card renderer;
plus the note that only the render path changed (matching is unchanged; a
missing live stream means a stale streams FULLTEXT index).
Wire it into the Developer Guide nav (+ ru nav_translations) and cross-link it
from HTTP Request Handling. English source only; docs/ru is regenerated before
release.
The xc_fanout daemon is kept alive by bin/xc_fanout/run.sh (a Restart=always
loop that respawns the daemon within ~2s of any exit, SIGKILL included), but
run.sh itself was launched only once by `service boot`. If the supervisor died
(OOM, a stray kill) the daemon was left unsupervised and never came back after
its next exit — nothing re-launched the loop (fanout_binary only pkills the
daemon and relies on it; StartupCommand only fixes its mode).
cron:root_signals (root, every minute, every node) now checks the supervisor is
alive and re-launches it as xc_vm when absent — a cheap pgrep, idempotent
(run.sh self-limits via the socket bind), matching `service boot`. Verified
live: killing both run.sh and the daemon self-heals within one minute.
Replace the server-side HTML builder in the `search` admin-ajax action with a
structured JSON contract: each result item now carries `{id, url, text, entity,
data}` and the client renders the card. Permission checks, status resolution and
category/server lookups stay server-side; only markup moves to the browser. This
unblocks the upcoming redesign, where the embedded HTML builder was in the way.
- SearchAjaxController::search() now emits structured items (per-entity `data`
payloads for stream/movie/channel/radio/episode, series, user, line, mag,
enigma) plus a `no_results` item for empty responses. Data-gathering is
unchanged (byte-identical to the previous fuzzy full-text gather).
- New client renderer `assets/admin/js/search.js` (`renderSearchItem`), loaded
before common.js; the Select2 quick-search `templateResult` now calls it
(with a loading-state guard) instead of consuming a server `html` field.
- ADR docs/adr/search-json-contract.md documents the envelope, item shape,
self-describing actions and per-entity payloads.
Note: this changes only the search render path, not search matching. Live
streams missing from results are a stale streams FULLTEXT index on the DB
(rebuild with `ALTER TABLE streams ENGINE=InnoDB`), unrelated to this change.
Rewrite the header docblocks of the Admin\Ajax controllers to describe purpose
and actions only, and drop the migration-history commentary (extracted-from-
api.php / ported-verbatim / byte-identical / empty-then-flattened / flagged
notes) from both docblocks and inline comments. Kept the genuinely useful
behavioural notes (no-gate actions, raw-echo responses, {status} envelopes,
in-process report dispatch, shared trait/helpers).
Comments only — no code or behaviour change.
Verified: php -l (all 17), make gates, make phpstan (0), make cs.
With all ~90 actions extracted into dedicated Admin\Ajax controllers, the last
tie to the legacy file was AjaxController requiring it as a fallback. Fold that
fallback (XHR guard + {"result":false} for unknown/removed actions) directly
into AjaxController::index() and delete Views/admin/api.php.
The `api` route is unchanged, so an action not peeled off by
Router::dispatchApi() still gets a proper JSON {"result":false} (not a 404).
Admin auth is already enforced by AdminScopeBootstrap before dispatch, so the
controller no longer needs the include/chdir/session dance.
api.php is gone (4985 lines at the start of this refactor -> removed).
Verified: php -l, make gates, make phpstan (0), make cs.
The bulk multi action resolved its device permission from a map keyed
'enigma2', but the frontend (enigmas.php) sends type=enigma and the switch
matches 'enigma'. So for Enigma2 devices the lookup returned null,
Authorization::check('adv', null) always failed, and every bulk op
(delete/enable/disable/ban/unban/purge/convert) silently returned
{"result":false}.
Resolve the permission directly instead: mag -> edit_mag, enigma -> edit_e2
(the same permission the single-device enigma action uses), via $this->gate().
This is a behaviour change: Enigma2 bulk actions now work.
Verified: php -l, make gates, make phpstan (0), make cs.
Thirteenth and final slice of the admin/api.php readability refactor.
- Add MultiAjaxController for the `multi` bulk-operations action. The api.php
block was an 8-level-deep if/else pyramid; it is now a thin switch($type)
dispatcher over per-entity private handlers (handleLine/handleDevices/
handleUser/handleServers/handleSeries/handleStreams), each a flat if/elseif
chain answering via ok()/fail(). The repeated
`implode(',', array_map('intval', …))` and connection-purge idioms are
factored into inList()/purgeUserConnections()/purgeServerConnections().
Behaviour and SQL preserved (verified: no new query/call introduced; only the
intended de-duplication reduces occurrence counts).
- Register `multi` via $router->api(...) and remove the block from api.php.
With every action now handled by a dedicated controller, api.php drops from
4985 to 37 lines — a session-gated fallback returning {"result":false} for
any action not peeled off by Router::dispatchApi() — and its ~40 now-unused
imports are removed.
Known issue left untouched here (preserved from api.php, flagged in-code): the
mag/enigma permission map is keyed 'enigma2' while $type is 'enigma', so enigma
bulk ops resolve a null permission — to be addressed separately.
Verified: php -l, make gates, make phpstan (0), make cs.
Twelfth slice of the admin/api.php readability refactor — the single largest
block (the global fuzzy search across lines, MAG/Enigma2 devices, users,
streams and series, each rendered as an HTML result card).
- Add SearchAjaxController. Because the block is dominated by large literal
HTML string-building, the body was lifted from api.php byte-for-byte (verified
identical to the original, whitespace-insensitive) rather than retyped, then
its 53 empty-then `if (c) {} else {…}` idioms were flattened to `if (!c) {…}`
by a mechanical, negation-preserving pass (each inversion reviewed). HTML and
control flow are otherwise unchanged; $language / $rSearchStatusArray are the
legacy bootstrap globals it relies on.
- Register `search` via $router->api(...) and delete its block from api.php
(1300 -> 452 lines; 4533 removed across the twelve slices).
Only the `multi` bulk-operations block now remains in api.php.
Verified: php -l, make gates, make phpstan (0), make cs.
Eleventh slice of the admin/api.php readability refactor.
- Add ProviderAjaxController: provider (delete/reload), provider_streams
(DataTables listing; gates inline since its permission-denied path uses the
DataTables envelope, not {"result"}).
- Add MiscAjaxController for the leftover small actions that fit no larger
domain: process, profile, watch_output, reguserlist, userlist, listdir,
queue, delete_recording, clear_failures. Nested empty-then cascades
flattened; OR-permission gates use gateAny(); comments English.
- Register all 11 actions via $router->api(...) and delete their blocks from
api.php (1587 -> 1300 lines; 3685 removed across the eleven slices).
Only the two large blocks, search and multi, now remain in api.php.
Verified: php -l, make gates, make phpstan (0), make cs.
Tenth slice of the admin/api.php readability refactor.
- Add BackupAjaxController with 4 actions: clear_logs, backup, report,
download_panel_logs. Logic ported faithfully; nested empty-then / if-else
cascades flattened; comments English; dead $rRet dropped.
- report is the sole direct-navigation action (opened via window.location, no
XHR header), so report() does not call requireXhr(); it dispatches
TableController in-process and converts the captured output to a CSV/JSON
download from a shutdown hook — ported verbatim.
- clear_logs' 6-permission gate uses gateAny(); download_panel_logs keeps its
(pre-existing) lack of a per-action gate.
- provider_streams (a provider-domain action interleaved in the range) is left
in api.php.
- Register the 4 actions via $router->api(...) and delete their blocks from
api.php (1787 -> 1587 lines; 3398 removed across the ten slices).
Verified: php -l, make gates, make phpstan (0), make cs.
Ninth slice of the admin/api.php readability refactor.
- Add StatsAjaxController with 3 actions: graph_stats, stats, header_stats.
Logic ported faithfully; the many nested empty-then cascades flattened;
comments English; dead locals / unused foreach keys dropped.
- stats was the file's largest block (~242 lines): the dashboard aggregation
with its $getFallbackUptime closure and single-server vs whole-fleet /
redis vs non-redis branches. $rTotalConnections is now initialised to 0 up
front so the redis path never reads it undefined.
- graph_stats keeps its (pre-existing) lack of a per-action permission gate.
- queue (a job-queue action) is left in api.php.
- Register the 3 actions via $router->api(...) and delete their blocks from
api.php (2186 -> 1787 lines; 3198 removed across the nine slices).
Verified: php -l, make gates, make phpstan (0), make cs.
Eighth slice of the admin/api.php readability refactor.
- Add PackageAjaxController with 8 actions: package, code, hmac, group,
bouquet, category, get_package, get_package_trial. Logic ported faithfully;
nested empty-then cascades flattened; comments English.
- get_package / get_package_trial keep their (pre-existing) lack of a
per-action permission gate; their identical bouquet-resolution loop is
shared via collectPackageBouquets().
- Register the 8 actions via $router->api(...) and delete their blocks from
api.php (2387 -> 2186 lines; 2799 removed across the eight slices).
Verified: php -l, make gates, make phpstan (0), make cs.
Seventh slice of the admin/api.php readability refactor — the Streams/VOD
domain, split into two controllers.
StreamAjaxController (content control): stream, movie, episode, series.
- movie and episode were byte-identical bar the permission key -> shared
vodAction(); delete/kill/purge were identical across stream/movie/episode ->
shared streamMutation(). start/stop/restart/force echo the raw ApiClient
response, as api.php did.
StreamToolsAjaxController (tools/lists/reviews): review_selection,
review_bouquet, serieslist, streamlist, adaptivelist, titlesync, probe_stream,
check_stream, get_episode_ids.
- review_bouquet's four near-identical member-collection blocks fold into a
collectBouquetItems() helper; the Select2 lists keep JSON_PARTIAL_OUTPUT_ON_ERROR;
probe_stream/check_stream echo their raw HTML tables. Empty-then cascades
flattened throughout; comments English.
process, provider and profile (interleaved but other domains) are left in
api.php. Register all 13 actions via $router->api(...) and delete their blocks
(3142 -> 2387 lines; 2598 removed across the seven slices).
Verified: php -l, make gates, make phpstan (0), make cs.
Sixth slice of the admin/api.php readability refactor.
- Add UserAjaxController with 5 actions: line, line_activity, adjust_credits,
reg_user, ticket. Logic ported faithfully; nested branches flattened;
comments English.
- Add LineStateTrait for the enable/disable/ban/unban/kill sub-actions that
were byte-identical across the line, mag and enigma blocks (they all act on
a line's user_id). DeviceAjaxController now uses the trait instead of its own
private copy; its now-unused imports were dropped. The trait carries
@phpstan-require-extends plus abstract ok()/fail() stubs so both PHPStan and
Intelephense resolve the never-returning helpers.
- process (process-monitor) and profile (transcode profile) were left in
api.php — they are not user/line actions.
- Register the 5 actions via $router->api(...) and delete their blocks from
api.php (3330 -> 3142 lines; 1843 removed across the six slices).
Verified: php -l, make gates, make phpstan (0), make cs.
Drop the cryptic "Group A/B/C…" prefixes from the page-route section headers,
keeping the descriptive name in the same boxed style, and render the ajax
API-action sub-sections (Cache & Handlers, Servers & Ops, Blocklists &
Security, Devices, EPG) in the same style. Also fixes one section comment that
was left in Russian.
Comments only — no route or behaviour changes.
Fifth slice of the admin/api.php readability refactor.
- Add EpgAjaxController with 8 actions: epg, epglist, force_epg,
epg_auto_assign, epg_categories, provider_import_epg, get_epg, get_programme.
Logic ported faithfully; nested empty-then cascades flattened; comments
English; dead locals dropped ($rGroups, $rChannelsSort, $rBestProvNorm).
- Actions answering with a {"status": STATUS_*} envelope (epg_auto_assign,
epg_categories, provider_import_epg) gate inline via Authorization::check(),
since their permission-denied path also uses that envelope, not {"result"}.
- BaseAjaxController::json() gains an optional $flags argument so epglist can
keep JSON_PARTIAL_OUTPUT_ON_ERROR.
- provider_streams (a provider-domain action interleaved in the range) is left
in api.php.
- Register the 8 actions via $router->api(...) and delete their blocks from
api.php (3750 -> 3330 lines; 1655 removed across the five slices).
Verified: php -l, make gates, make phpstan (0), make cs.
Fourth slice of the admin/api.php readability refactor.
- Add DeviceAjaxController with 4 actions: mag, enigma, mag_event, send_event.
- mag and enigma were near-identical; their shared line-state sub-actions
(enable/disable/ban/unban/kill, all keyed on the device's owning line
user_id) are factored into a private lineStateAction() helper, leaving only
the differing delete/convert calls in each method.
- send_event: the nested type if-else cascade flattened to an elseif chain.
- Register the 4 actions via $router->api(...) and delete their blocks from
api.php (3966 -> 3750 lines; 1235 removed across the four slices).
Verified: php -l, make gates, make phpstan (0), make cs.
Third slice of the admin/api.php readability refactor.
- Add BlocklistAjaxController with 7 actions: useragent, isp, mysql_syslog, ip
(blocklist management), ip_whois (GeoIP/ISP/ASN lookup — no per-action gate,
as in api.php), asn (block/allow ASN), decrypt_text (stream-token decrypt).
Logic ported faithfully; empty-then conditionals flattened; comments English.
- ip_whois: silence the pre-existing undefined-key warning on the optional
`isp` sub-array via !empty() (behaviour unchanged — the ASN branch only ran
with isp data present).
- Register the 7 actions via $router->api(...) and delete their blocks from
api.php (4199 -> 3966 lines; 1019 removed across the three slices).
Verified: php -l, make gates, make phpstan (0), make cs.
Simplify the ported `if (cond) {} else { body }` idioms (empty then-branch)
into positive form `if (!cond) { body }`, invert the fingerprint validation
guard and de-indent its body, and fold `if (c) {$e=true;} else {$e=false;}`
into `$e = (c);`. Also drop two dead assignments ($rSuccess, the unused catch
binding $rE). All changes are behaviour-preserving.
Verified: php -l, make phpstan (0), make cs.
Second slice of the admin/api.php readability refactor, plus housekeeping.
- Relocate the extracted ajax controllers into a dedicated
XcVm\Public\Controllers\Admin\Ajax namespace/folder (BaseAjaxController,
CacheAjaxController moved here from the previous slice).
- Rewrite the ajax-controller docblocks/comments in English (repo standard
going forward).
- Add BaseAjaxController::gateAny([[type,key],...]) for the OR-permission
idiom `if (check(a) || check(b))` used by the server-view family.
- Add ServerAjaxController with the 17 Servers/Ops actions: rtmp_ip,
rollback_versions, server, proxy, fingerprint, restart_all_services,
restart_services, reboot_server, update_binaries, server_view, server_stats,
rtmp_kill, install_status, reinstall_server, fpm_status, update_all_servers,
update_all_binaries. Block logic ported verbatim; the repeated server_id
normalize idiom folded into a private helper.
- Register the 17 actions via $router->api(...) in routes/admin.php and delete
their blocks from api.php (4825 -> 4199 lines; 786 removed across both slices).
Two deliberate departures from a byte-for-byte port, both documented in the
controller:
- rtmp_kill echoes the raw ApiClient::systemRequest() response (not JSON), as
api.php did.
- proxy->kill previously read an undefined $rServerID (a latent bug:
getAll()[null] -> no-op + warnings in redis-handler mode); it now uses
server_id so the redis path actually closes the proxy's connections. This is
a behaviour change on that path — flagged for review.
Verified: php -l, make gates (incl. check-procedural-use), make phpstan (0),
make cs. Actions still gated identically and reached via Router::dispatchApi()
before the AjaxController fallback.
First slice of the admin/api.php readability refactor. api.php is a ~4985-line
flat chain of `if (action == 'x') { … exit(); }` blocks with heavy boilerplate
(≈96 permission-gate/else-fail pairs, ≈292 exit()s). Actions are peeled off one
group at a time via Router::dispatchApi(), which runs before the AjaxController
→ api.php fallback, so migrated actions leave api.php untouched for the rest.
- Add BaseAjaxController: JSON-only base (POPO, like TmdbController) providing
ok()/fail()/gate()/requireXhr()/json(), collapsing the repeated
Authorization::check(){}else{json false;exit} obvious into `gate()`. The XHR
guard from api.php's top is replicated per-action so behaviour is identical.
- Add CacheAjaxController with the 6 Cache & Handlers actions (regenerate_cache,
enable_cache, disable_cache, enable_handler, disable_handler, clear_redis).
Block logic ported verbatim — no behaviour change.
- Register the 6 actions via $router->api(...) in routes/admin.php and remove
the corresponding blocks from api.php (4985 → 4825 lines).
- Annotate $router with @var in routes/admin.php to resolve the pre-existing
"Undefined variable $router" IDE warning (FQN, no use-import).
Verified: php -l, make gates (incl. check-procedural-use), make phpstan (0),
make cs. New classes autoload via PSR-4 (no dump needed).
Surfaced in the panel error log:
- api.php: the server 'delete' action read $rServers[server_id]['is_main']
without an isset() guard, so deleting an id absent from $rServers (already
removed / orphan) raised "Undefined array key" + null offset access. Mirror
the isset() guard already used at the 'restart' action.
- post.php: the server-save SSL regeneration path read $rData['domain_name']
in is_array()/explode() with no key check, warning twice when the field was
omitted. Normalise a missing key to an empty array.
- CertbotCommand: file_put_contents() wrote the run log into certbot/logs/,
which certbot only creates via --logs-dir when it actually runs; with no
domains (or on a fresh server) the dir was absent and the write failed.
mkdir it before writing, ahead of the chown so ownership is fixed too.
The line-by-line web translator reordered words inside `**bold**` spans and
misplaced/dropped the markers, producing `**LB` or `****` (empty bold). Mask
each `**...**` as ONE atomic sentinel: translate the inner text on its own,
then store the whole balanced `**inner**` — the engine never sees the markers
and cannot reorder or collapse them. Also harden the anthropic prompt to keep
emphasis balanced.
Auto-prune: after translating, delete generated docs/ru files whose docs/en
source no longer exists (renamed/removed) and drop now-empty dirs, so the tree
mirrors docs/en 1:1 (removes the stale development/modules.md and
guides/geoip-and-device-detection.md).
Bump PROMPT_VERSION to 6 to invalidate the contaminated cache and regenerate
docs/ru (0 broken bold spans remaining, aside from pre-existing multi-line
bold that spans a soft line break).
- Move "Pre-Release Validation" to step 2 (right after Changelog) and fold the
"Regenerate translated documentation" sub-step (make docs-translate/build)
into it, so code checks and doc regeneration happen together, early.
- Run `make new` as the very first action (step 1), before generating
dist/changes.md, and drop it from "Build Archives". `make new` wipes AND
recreates dist/, so running it in the build step deleted the changes.md
generated in step 1; main/lb don't depend on new and their final `clean`
only removes TEMP_DIR, so changes.md now survives to the GitHub Release step.
- Renumber sections and update the make-new command reference.
handleLiveConnections() set $rKeyCount in every branch except the
`refresh` request path: there $rFilterBefore stays true, so the
`if (!$rFilterBefore) $rKeyCount = count($rKeys)` fallback is skipped and
the later `$rKeyCount--` in the mGet loop hit an undefined variable
(WARNING: Undefined variable $rKeyCount at TableController.php:2741).
Initialize $rKeyCount = count($rKeys) in the refresh branch — the count
starts at the number of requested keys and is decremented for dead/filtered
rows, matching the non-filter-before path. (Public/ is index-only for
PHPStan, so this was never surfaced by static analysis.)
Audited every require/include in src/. Removed 45 manual require_once/require
statements that load a XcVm\* class in a context where the Composer autoloader
is already registered — the class resolves on first use, so the require is dead:
- CronTrait ×26 (Cli/CronJobs/*), DaemonTrait ×7 (Cli/Commands/*)
- DropboxClient ×5 (Core/Backup/BackupService)
- XmlStringStreamer (EpgCronJob; was a bare `require`, a redeclare risk)
- Thread + Multithread (CacheEngineCronJob)
- Logger, DatabaseHandler, LegacyInitializer, Translator (bootstrap.php — the
autoloader is registered earlier at line 89; these are also `use`-imported)
Deliberately KEPT (not redundant):
- The lightweight WebApiBootstrap / StreamingRequestBootstrap / StreamingBootstrap
have NO Composer autoloader (intentional, for high-traffic endpoints) — their
manual requires are load-bearing.
- ErrorHandler.php (global namespace, not PSR-4), and the define()-only config
files AppConfig.php / Paths.php / Binaries.php (autoload never loads constants).
- All view/template/procedural/dynamic includes.
Every removed target verified to resolve via the production autoloader
(class_exists / trait_exists). make phpstan / cs / gates all green.
The front controller selected a pair of procedural files per scope
(<scope>_session.php + <scope>_functions.php) from a hardcoded map and
require'd them at global scope. Consolidate into a typed class hierarchy:
- ScopeBootstrap (interface) + ScopeBootstrapFactory::create($scope)
- Admin/Reseller/PlayerScopeBootstrap, each porting its former session +
functions logic 1:1 into boot()/bootSession()/bootFunctions().
index.php now calls ScopeBootstrapFactory::create($scope)->boot() instead
of building file paths and require'ing. Unknown scopes still fall back to
admin.
Behaviour is unchanged: the view-facing globals ($rUserInfo, $rPermissions,
$_STATUS, $customScript, $_PAGE, server/health flags) are still injected via
explicit `global` declarations in the boot methods, so the ~140 procedural
view templates read them from scope exactly as before. player_utility_-
functions.php is unchanged and still loaded by PlayerScopeBootstrap.
Also fix the PHPStan stub: SERVER_ID is an int (getMainID(): ?int, a
server_id PK), not a string. Moving its define() into a class method meant
PHPStan no longer inferred the type from a top-level define and fell back to
the stub, which wrongly typed it `(string) mt_rand()` — surfacing 20
false argument.type errors at int-param call sites. Stub now uses mt_rand().
Removes: admin_session_fc.php, admin_functions_fc.php, reseller_session.php,
reseller_functions.php, player_session.php, player_functions.php.
make phpstan / cs / gates all green.
Drop build/phpstan-baseline.neon (122 frozen pre-existing errors) and fix
the underlying issues in source instead of suppressing them. A fresh
level-5 run without the baseline surfaced 158 errors across 56 files; all
are resolved. `make phpstan`, `make cs`, and `make gates` are green.
Fix categories:
- variable.undefined — initialize vars to a correct default before the
branch/loop that conditionally set them, so every path defines them.
- return.type — align declared return types with reality (widen to
array|false / ?array / \Movie|null etc. where callers handle the
sentinel, or return the declared type consistently).
- argument.type — cast at call sites (curl_setopt/stream_set_blocking bool
flags, str_replace/mktime/uniqid operands, \CurlHandle phpdoc).
- redundant/dead conditions — simplify always-true guards and drop
unreachable else branches, preserving behavior.
Real latent bugs caught along the way:
- Core/Http/CurlClient — retry loop never incremented on failure and never
broke on success (could spin); now retries as documented.
- Core/Util/StreamUtils — explode('=', $x, 1) meant path/domain query
params were never parsed (limit 1 -> 2).
- Cli/CronJobs/RootSignalsCronJob — set_governor emitted an undefined PHP
$i into the cpufreq-set bash command (now a literal shell $i).
Root-cause analyzer fixes (not suppressions):
- Database::ping()/num_rows() marked @phpstan-impure so repeated calls are
not treated as constant (clears the ResellerApiDispatcher false cluster).
- StreamService::getArchive() $rReturn given an explicit @var for the
loop-accumulated shape PHPStan cannot infer from array().
Two residual entries are documented false positives (analyzer limitations,
not bugs) as path-scoped ignoreErrors in phpstan.dist.neon:
- CacheHandlerCommand:66 — settings force-reloaded from DB mid-run.
- EpgCronJob:314 — reconnect-verify ping() after db_connect().
Also drops an unused `use ...Epg\EPG;` import in admin/api.php.
cron:tmp (TmpCronJob) deletes every file directly in tmp/ older than 600s,
so the continuously-running fanout daemon's tmp/xc_fanout.log was unlinked
out from under it — the daemon kept writing to the orphaned inode (log
invisible, space held until restart).
Write to bin/xc_fanout/xc_fanout.log (never swept) and truncate (>) per
daemon generation so it stays bounded — same convention as the PHP daemons'
tmp/daemon_<name>.log.
Harden the hot paths that dominated the panel error log with defensive
null/undefined-key guards; no behaviour change on the happy path:
- ConnectionTracker::removeFromQueue — skip when the queue signal file is
absent instead of file_get_contents() on a missing path (219 warnings)
- ConnectionTracker::closeConnection — fill server_id/pid/activity_id/etc.
defaults on partial connection arrays before comparisons
- ShutdownHandler — empty()-guard redis_handler so a null settings cache
no longer throws "array offset on null"
- SeriesService — coerce json_decode(bouquet/category_create_list) to array
before foreach (create + update variants)
- PlayerApiController — coerce movie_properties to array and ?? its keys
- TableController — ?? "" on movie_image before strlen (2 sites)
- admin/api.php — init $rInfo codec defaults for audio-less streams and
?? 0 the iostat avg-cpu chains
HLS viewers were over-tracked, especially on channel switch: each playlist
request through auth.php minted a fresh md5(uniqid()) uuid, and HLS
connections are keyed by uuid — so every (re)select created a NEW tracked
connection that the 30s/60s reaper only cleared slowly. A single viewer
switching channels ballooned to ~19 live connections and tripped
max-connections limits. Verified live on the test server.
Derive the HLS connection id from line identity + stream + client IP +
user-agent instead (new ConnectionTracker::hlsConnectionKey()), overriding
the token uuid for the m3u8 arm in live.php before the connection context is
built — so tracking, segment auth and the heartbeat file stay consistent.
The same player re-requesting the same channel from the same IP now reuses
ONE connection; different stream, IP or user-agent stay distinct — preserving
multiple streams from one IP and multi-device counting. TS/VOD keep their
per-request random uuid.
Verified: same player x4 -> 1 connection; different UA -> separate;
different stream -> separate.
Adds a "safety net" rollback so a server can be downgraded to an earlier
release without a manual redeploy, mirroring the existing update flow.
- GitHubReleases: getVersionFile() fetches a specific version's asset (not
just latest); getPreviousVersions() lists the newest releases older than a
given version, each flagged beta (GitHub prerelease). getReleases() now
delegates to a shared getFilteredReleases() that keeps the prerelease flag.
- UpdateCommand: new `rollback <version>` case — validates the target is a
real, strictly-older release; on MAIN takes an automatic DB backup first
(aborting if it fails); downloads the exact version (main/lb_update asset),
verifies MD5, and hands off to the same python `update` applier.
- RootSignalsCronJob: dispatch the `rollback` signal to `console.php update
rollback <version>` (version regex-validated, escapeshellarg'd).
- api.php: `rollback_versions` (channel-aware list, optional per-server base
version) and server `sub=rollback` (validates version, writes the signal).
- servers.php: per-server "Rollback Version" action (dropdown item + button)
opening a modal to pick a version; beta builds show "(beta)". Works for
MAIN and each LB independently.
- docs: rollback sections in server-update.md and update-system.md.
The downgrade reuses the version-agnostic python applier, so binaries,
config and user data are preserved. Migrations are forward-only and are not
rolled back; the automatic MAIN backup is the recovery path.
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.
Export as CSV produced an empty download: the report action fetched its
rows over a loopback HTTP call to 127.0.0.1:<http_broadcast_port>/table,
which is unreachable under the Front Controller, and the button navigates
via window.location so the api.php XHR guard exit()ed the request in prod.
- api.php: exempt the `report` download action from the X-Requested-With
guard (still gated by session + `backups` permission), and replace the
dead loopback curl with in-process TableController dispatch (as in
AdminAPIWrapper::TableAPI), capturing its JSON via a shutdown hook and
converting to CSV or JSON per the new `format` param.
- topbar.php: add an "Export as JSON" button next to every "Export as CSV"
entry and widen the permission gate to cover both.
- listings.js: add a delegated #btn-export-json handler that exports the
visible ./table DataTable (current filters/sort) as JSON.
Porting the portal to Ministra 5.6.10 (re-)enabled two client-side cut_off() gates the XC_VM fork had kept off in 2.3.9: the STB-type gate (commented out in 2.3.9) and a brand-new 'outdated_firmware' gate. Both reject legit boxes ("your set-top box is not supported" / "outdated_firmware") even though Allowed STB Types is enforced server-side (portal.php get_profile / $rVerified) and the backend sends no autoupdate data — which is exactly why the 2.3.9 xpcom.common.js works as a drop-in while 2.4.1 does not. Expose both as top-level flags (XCVM_ENFORCE_STB_TYPE / XCVM_ENFORCE_FIRMWARE, default false) and guard the two branches with them, restoring 2.3.9 behaviour while keeping the upstream code reachable by flipping a flag. A full 2.3.9-vs-2.4.0 diff confirmed these are the only box-rejection gates that differ (init/load/handshake/get_server_params are otherwise identical bar formatting).
A Stalker STB pings <portal_base>/server/load.php (load-balancer handshake) and may follow to server/login. Under an access-code portal path (/CODE/server/load.php) these have no file and no route, so the front controller falls to the admin-session fallback (ministra is an unknown scope), whose relative ./login redirect resolves back onto the same /CODE/server/... path and 302-loops forever — the box hangs on authorization. The nginx shim only catches the root /server/load.php (the ^~ /CODE/ code block preempts regex locations), so handle the code-path case in index.php: for the ministra scope, redirect server/load.php|server/login to portal.php in the SAME base (query preserved), which answers the handshake. Verified live: /CODE/server/load.php now serves handshake/get_profile/do_auth and the box browses VOD; normal admin login redirects unchanged.
- RedisManager::close(): swallow exceptions from $rRedis->close(). A half-dead socket throws RedisException ('read error on connection') on close, which propagated through reconnect() -> ConnectionTracker::getCapacity() and killed the watchdog daemon. Teardown must never fatal — the instance is discarded either way and reconnect() opens a fresh one.
- TableController: guard $rRow['hmac_id'] with !empty() in both live-connections renderers (lines 2567 and 2891). Non-HMAC connections have no hmac_id key, raising 'Undefined array key' on every render.