The delayed-HLS branch inline-computed two things: the resume segment number
parsed from the existing delay playlist (last-or-previous line's _<n>.ts index),
and the delay sleep seconds (delay_minutes*60 reduced ~10s per already-produced
segment, floored at 0). Pull both into pure resolveDelaySegmentStart() and
resolveDelaySleepTime() helpers with unit tests. The playlist merge and the
short-playlist abort stay inline. No behaviour change.
Verified: phpstan level 5 green, phpunit 391/391, make gates green.
The ffprobe-skip branch of the source loop hand-rolled two things: a scan of the
stream arguments for skip_ffprobe == 1, and the literal assumed h264/aac mpegts
result used when the scan matched. Give both names via pure helpers and cover
them with unit tests. No behaviour change.
Verified: phpstan level 5 green, phpunit 385/385, make gates green.
The 300s ffprobe cache is written, existence-checked and deleted under
md5($rSource), but the content read used md5($rStreamSource)
(= parseStreamURL($rSource)). When parseStreamURL rewrote the source the read
fetched a different, usually missing file, so igbinary_unserialize(false)
failed the validity check and every start re-probed instead of hitting the
cache. Read under md5($rSource) so the key matches the write.
Verified: phpstan level 5 green, phpunit 382/382, make gates green.
phpstan-bootstrap.php and phpstan.dist.neon claimed the project "has no
Composer and no PSR-4 namespaces" and referenced the removed src/autoload.php
— both false since the Composer PSR-4 migration (and self-contradicted by the
neon's own excludePaths note about src/vendor/autoload.php). Comment-only:
they now state the bootstrap defines constants and deliberately wires no
project autoloader (PHPStan discovers symbols via paths + scanDirectories).
No behavior change.
The script was never wired up (no `make module-hashes` target, no CI/Makefile
caller). Module hash_id generation now lives in the standalone Module_Template
kit; for existing modules, generate inline with
`php -r 'echo bin2hex(random_bytes(16));'`.
Updated the module-dev docs (en + ru) that referenced the removed script /
non-existent `make module-hashes` target to use the one-liner.
The {AAC_FILTER} substitution carried the rule inline: emit -bsf:a
aac_adtstoasc only when a copied AAC audio stream is muxed into a non-FLV
container. Give that rule a name and a test via a pure aacBitstreamFilter()
helper. No behaviour change.
Add unit tests: applied for copied AAC in mpegts/empty container, skipped for
FLV, skipped when the codec is not AAC or the output is not a copy.
Verified: phpstan level 5 green, phpunit 382/382, make gates green.
The codec-metadata derivation persisted for a started live stream (player
compatibility, audio/video codec names, resolution snapped to the nearest
standard height) was an inline block reading ffprobe output. Pull it into a
pure resolveStreamCodecMeta() helper taking the ffprobe array and the
player_allow_hevc flag and returning [compatible, audio, video, resolution].
The only external input, SettingsManager::getAll()['player_allow_hevc'], is now
passed in (getAll() just returns the cached settings array, so hoisting the read
out of the guard is free). Add unit tests: non-array/missing-codecs defaults,
compatible h264/aac, resolution snapping, HEVC gating by the allow flag, and
incompatible-but-reported codecs.
Verified: phpstan level 5 green, phpunit 379/379, make gates green.
The X-XC_VM-Detect / X-XC_VM-Prebuffer header injection was duplicated three
times in startStream: each block looped the argument list to append the header
to an existing 'headers' entry and added a new one if absent. Collapse the three
copies into a single appendHeaderArgument() helper and drop the shared
$rProcessed flag they threaded through (it leaked state between the blocks).
Behaviour is unchanged: the helper uses a local applied flag, and the three call
sites produce the same argument lists as before. Add unit tests covering
append-to-existing, create-when-absent, append-to-every-headers-entry, and the
empty-list case.
Verified: phpstan level 5 green, phpunit 373/373, make gates green.
Restores the pointer the engineering skills use to find the per-repo
issue-tracker and domain-doc config (docs/agents/*), lost during a
concurrent history rewrite.
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.
dependabot.yml: the comment claimed "no Composer/npm manifests committed",
but src/composer.json + composer.lock ARE committed. Added a grouped
composer ecosystem (directory /src) so the 4 prod deps and the dev tools
get advisory monitoring; noted the production-only vendor recommit step.
instructions/php-conventions + architecture-rules: rewritten to the
current Composer PSR-4 architecture. They previously described the
pre-migration state and misdirected Copilot:
- "No autoloading via Composer — custom src/autoload.php" (file removed)
and "Do NOT introduce Composer dependencies" → Composer PSR-4, vendor
committed production-only, dump-autoload workflow
- lowercase paths (src/cli, src/core, src/modules, …) → real PascalCase
(src/Cli, src/Core, src/Modules, src/Streaming, …)
- setDb() / $r-prefix (both gone from the codebase) → DatabaseAware +
self::db(); dropped the dead $r naming rule
- inverted namespace guidance → new code is namespaced; legacy coexists,
don't mass-migrate
- dangling ARCHITECTURE.md ref → docs/en/development/architecture.md
(which exists), src/config/modules.php path fixed
The 6 kept subagents taught facts the codebase has since removed, and
each carried ~135 lines of duplicated memory-protocol boilerplate with a
broken hardcoded path (/media/divarion/... — this repo is /mnt/FILES/...).
Fact corrections (verified against the code):
- "no Composer / custom XC_Autoloader" → Composer PSR-4 (XcVm\ → src/),
vendor/ committed production-only (XC_Autoloader no longer exists)
- setDb() static-injection DI → DatabaseAware trait + self::db()
(self::db() in 34 files; setDb down to 1 legacy file; do not reintroduce)
- src/modules/{name}/ → src/Modules/{name}_{hash5}/, ns XcVm\Module\{Pascal}
- db-architect: MigratableInterface migrations → real file-based
ModuleMigrator (database.sql + database_drop.sql + migrations/<semver>.sql)
- qa: dropped brittle "210/210" test count for the phpunit command
- global $db kept — it is accurately described as mid-migration
Memory block: replaced the boilerplate with a concise footer that points
at the correct relative .claude/agent-memory/<name>/ path and relies on
the `memory: project` frontmatter. Files shrank ~45%.
Remove 7 subagents that were never used (empty agent-memory) and/or
overlapped heavily with the kept reviewers or built-in /code-review and
/security-review:
- ceo-team-coordinator, business-analyst, risk-officer — enterprise
"consulting firm" roles that don't fit a solo-dev PHP IPTV panel
- solution-architect, technical-auditor — duplicate php-architect-reviewer
- performance-engineer, tech-doc-writer — niche, never invoked
Kept the 6 with proven use and distinct axes: backend-lead-reviewer,
security-architect, qa-lead-reviewer, devops-lead-reviewer,
php-architect-reviewer, db-architect.
buildSubtitleImport nested the metadata loop inside the import loop and reused
the outer counter $i. The inner loop advanced $i to count(files), so the outer
import loop exited after its first iteration: only the first subtitle was
imported (-sub_charenc -i), while -map/-metadata was still emitted for every
file — mapping ffmpeg inputs that were never added. Multi-subtitle movies got a
single imported track plus dangling -map references (broken output).
Split the nested loop into two siblings — import every file, then map every
file — and cache the count. All configured subtitles are now imported and
mapped to the correct input index.
Add tests/Unit/StreamProcessSubtitleTest.php covering empty input, a single
local subtitle, remote-server fetch, and the multi-subtitle case that
reproduces the defect (import count was 1, must equal N). Verified red→green
against the pre-fix code.
Verified: php -l clean, make gates green, phpunit 336/336 green.
Move the VOD subtitle import + metadata assembly out of startMovie into a
private helper so the method is smaller and the subtitle logic is isolated.
Behaviour is preserved verbatim, INCLUDING a pre-existing bug: the inner
metadata loop reuses the outer import loop's $i counter, so the import loop
effectively runs once (first file) while metadata is mapped for all files.
Left as-is here; it is a real defect to fix in a dedicated change.
Verified: php -l clean, make gates green.
createChannelItem, startMovie and startStream each inlined the same blocks
verbatim. Extract them into private helpers — removing the duplication and
shrinking the three oversized methods:
- buildLogoFilterOptions() — logo / -filter_complex overlay block (was inlined 3x)
- resolveGpuInputCodec() — CUVID hardware-decoder detection (was inlined 2x)
- applyDefaultCopyCodecs() — default -acodec/-vcodec to stream copy (was inlined 3x)
Pure extraction, behaviour preserved. startStream's GPU block is intentionally
left inline: it reuses the already-computed $rFFProbeOutput instead of
re-probing the source, so it is not the same code.
Verified: php -l clean, make gates green (PSR-4 use-imports, LB manifest,
vendor prod-only). net -28 lines.
Standalone Python (stdlib-only) tool that verifies a stream delivers
segments correctly and its delivery queue does not break:
- HLS: EXT-X-MEDIA-SEQUENCE contiguity, no dropped/rewound segments, no
EXT-X-DISCONTINUITY, every newly appearing segment downloadable.
- MPEG-TS: per-PID continuity_counter, sync-byte loss, TEI, delivery stalls,
with a --tolerance for rare source glitches relayed by -c copy.
- --live: colored TUI dashboard modelling a virtual player — received
timeline from PCR (TS) / EXTINF (HLS), playhead, and buffered cache
seconds graphed over time.
Documented in docs/{en,ru}/development/streaming-subsystem.md.
The live TS delivery loop in stream/live.php slept 1 second after every
read, capping client throughput at read_buffer_size per second (8 KB/s
with the default 8192 buffer) — far below the stream bitrate. This
starved the client mid-segment and then dumped the segment remainder in
a burst (sawtooth delivery), and throttled behind/HD clients below their
bitrate so they could never catch up.
Drain available data without sleeping; only pause (1s) and tick the
stall counter when caught up to ffmpeg's write head. Delivery now paces
to the real bitrate regardless of read_buffer_size or channel bitrate.
This matches the already-correct pattern in admin/live.php.
Verified before/after on a live endpoint: the 8 KB/s dribble phase is
gone and each segment is delivered promptly.
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
Reseller dashboard activity rows now resolve the target device/user
(line, user, mag, enigma) and render a link to it, falling back to
deleted_info for removed targets. Narrow the users_logs query to the
columns actually consumed and qualify the ORDER BY.
Fix renderUnifiedLayoutFooter so explicit page vars win over stale
globals and are also exposed in the footer's local scope; pass the
required vars from the reseller line/mag/enigma views.
Also fix broken quote nesting in the admin ASN table status buttons
that leaked literal quotes into the markup.
@-suppress imagepng() and file_put_contents() for image caching; a failed write
is already handled by the surrounding file_exists checks, so the raw warning is
noise.
Default $rCost to 0 on non-charge paths (credit math and logs unchanged) and
guard TMDb cast/crew iteration with ?? [] so missing credits data no longer
triggers undefined-variable / foreach-on-null warnings.
Use ?? on server status, divergence/series/name/user lookup maps, per-stream
server counts and movie property fields so admin tables and the movie edit form
render without undefined-key warnings when a row references data absent from the
supporting maps.
Add readStreamCache() to safely read+igbinary-unserialize transient cache files,
returning null when absent or corrupt. Series/channel/VOD paths now skip or return
empty payloads instead of cascading into file_get_contents/offset-on-bool/
foreach-on-null warnings when a stream is deleted or its cache is not yet warmed.
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.
@-suppress unlink() of transient _errors/connection files that may already be
gone, tolerate non-array provider category/stream feeds, default absent category
ids to '', and bail early from ServersCronJob when SERVER_ID is not yet in the
servers map instead of dereferencing null.
The top-right profile dropdown was a hardcoded HTML block in
admin/header.php, so modules could not add or remove entries and the
plex/watch "settings" links were baked into core.
Move the menu into the NavbarRegistry: CoreNavbarProvider now registers
the core items (edit profile, settings, backups, cache, modules, logout)
under a reserved 'profile' parent, and header.php renders
NavbarRegistry::getChildren('profile') adaptively. There is intentionally
no top-level 'profile' node, so the items never leak into the main
navigation. Order 100–980 is reserved for module-provided links.
Extract NavbarRegistry::collapseDividers() to drop separators orphaned by
permission filtering (leading, trailing, consecutive), and reuse the
shared _xc_nav_visible()/_xc_nav_label() helpers by defining them once
near the top of the header. Remove the hardcoded settings_plex /
settings_watch entries — those now belong to their owning modules.
Add NavbarProfileMenuTest covering collapseDividers, the core 'profile'
contract (no top-level leak, edit first / logout last, permission gates),
and the module-integration contract (reserved order slots, divider
collapse when folder_watch_settings is absent).
cron:servers only checked whether a watchdog process existed. A watchdog
blocked in poll() on a half-open MariaDB socket (CLOSE_WAIT) inside its DB
ping still exists, so cron kept trusting it indefinitely: last_check_ago
went stale and the panel marked the node offline while nginx, php-fpm,
redis and active streams were all healthy. Restarting only the watchdog
restored the heartbeat immediately.
Treat an abnormally old watchdog as stale. A normal generation lives only
a few seconds, so any process alive for more than 90s is killed and a
fresh generation is started; a plain presence check alone would keep
trusting the wedged one forever.
Add ProcessManager::getProcessAge(), which derives age from the mtime of
the /proc/PID directory (time() - filemtime) — the same figure as
`ps -o etimes` without shelling out, consistent with the rest of the class
reading /proc directly and with no HZ/CLK_TCK assumption. Reaping uses the
existing ProcessManager::kill() rather than a raw `kill -9`.
Recovery restarts only the watchdog; nginx, php-fpm, redis, ffmpeg and
running streams are untouched.
Two false positives/negatives in process detection kept the panel's
self-healing loop dead on any real installation:
- isNginxRunning() looked for "nginx: master" only among xc_vm-owned
processes, but the master runs as root on typical installs (workers run
as xc_vm), so cron:servers/cron:streams bailed out with "XC_VM not
running..." before reaching the daemon revival block. Now scans
/proc/*/cmdline user-agnostically, same as RootSignalsCronJob.
- The per-daemon "is it alive" checks piped ps through grep by bare words:
every live-stream ffmpeg carries -thread_queue_size/-max_muxing_queue_size
in its command line, so the "queue" check matched any running stream and
the encode queue daemon was never started while at least one channel was
up — created channels sat at "0% DONE" until console.php queue was run by
hand. All seven checks (signals, cache_handler, network, watchdog, queue,
ondemand, scanner) now use ProcessManager::findProcessPIDs(), a /proc
cmdline scan matching the daemon title (XC_VM[...]) or its exact
console.php invocation. Kill branches use the same PID list — the old
bare "ondemand"/"scanner" greps could kill an innocent ffmpeg whose
source URL contained those words.
- 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)