XC_VM Build System (MAIN vs LB)¶
How XC_VM produces two build variants from a single codebase: a full MAIN server and a lightweight Load Balancer (LB) server.
Build Variants¶
XC_VM supports two deployment roles from a single source tree:
| Variant | Archive | Purpose |
|---|---|---|
| MAIN | xc_vm.tar.gz |
Full application — admin panel, streaming, all modules, cron jobs |
| LB (Load Balancer) | loadbalancer.tar.gz |
Streaming-only server — no admin panel, no user management |
MAIN is the primary server that manages everything: admin UI, database writes, user/device management, EPG processing, backups, etc.
LB is a lightweight streaming node that receives streams from MAIN (or other sources) and delivers them to clients. It connects to the master database in read-only mode and has no admin panel or management capabilities.
Makefile Targets¶
| Target | Output | Description |
|---|---|---|
make main |
dist/xc_vm.tar.gz |
Full MAIN build |
make lb |
dist/loadbalancer.tar.gz |
LB build (streaming-only subset) |
make new |
(resets dist/) |
Remove and recreate the empty dist/ output dir — run before a build; builds nothing itself |
make generate_deleted_files |
src/migrations/deleted_files.txt |
List files removed since the last tag (see below) |
Updates reuse the full archive. There is no separate incremental-update target — the same
xc_vm.tar.gz/loadbalancer.tar.gzis used for both install and update; filtering happens on the server at update time (see Update Mechanism). To delete files that were removed between releases,make generate_deleted_files [LAST_TAG=vX.Y.Z]diffs git and writesdeleted_files.txt, which the updater applies. The LB archive'smigrations/deleted_files.txtalso lists every file the LB build strips (see LB Build — Deleted Files on Update).
Additional outputs:
XC_VM.zip— installer package (install/+xc_vm.tar.gz)hashes.md5— MD5 checksums for integrity verification
Composer Dependencies¶
src/vendor/ (the Composer PSR-4 autoloader plus the production dependencies) is
committed and shipped as-is — the deploy path has no Composer and never runs
composer install. It is kept production-only via composer install --no-dev, so
both build variants ship a lean vendor with no dev tooling.
src/composer.lockis committed socomposer installis reproducible.- Dev tools (PHPStan, phpcs) are
require-devand are not in the committed vendor or the archives. Developers and CI add them withmake dev-tools(composer install); thecheck-vendor-prod-onlygate fails if a dev package is ever committed undersrc/vendor/. - There is no build-time vendor step —
make main/make lbcopy the committedvendor/directly into the archive.
What Goes Into Each Build¶
MAIN Build¶
The MAIN build contains the entire src/ directory.
LB Build — Included Directories¶
Only these directories are copied into the LB archive:
Plus root files: bootstrap.php, console.php, service, update.
LB Build — Excluded Content¶
After copying, admin-specific content is removed from the LB build:
Directories removed:
| Path | Reason |
|---|---|
bin/install/ |
Installer scripts (not needed on LB) |
bin/redis/ |
Redis binary (LB doesn't run its own Redis) |
bin/nginx/conf/codes/ |
Error code pages (admin UI) |
Public/Controllers/Admin/ |
Admin panel controllers |
Public/Controllers/Player/ |
Player panel controllers |
Public/Controllers/PlayerV2/ |
Web player v2 controllers (player scope, not routed on LB) |
Public/Controllers/Reseller/ |
Reseller panel controllers |
Public/Views/ |
Panel templates |
Public/assets/ |
Panel static assets |
Public/routes/ |
Panel route maps |
Domain/User/ |
User management |
Domain/Device/ |
Device registration |
Core/Reference/ |
Admin reference-data classes (MAIN-only) |
Core/Localization/lang/ |
Language resource files (.ini) |
Files removed (these mirror LB_FILES_TO_REMOVE in the Makefile):
| File | Reason |
|---|---|
Public/admin/api.php, Public/admin/proxy_api.php |
Admin and proxy APIs (MAIN-only; the LB nginx routes only /admin/{live,timeshift,thumb,vod}) |
Public/stream/auth.php, Public/stream/probe.php |
Viewer auth and stream probe (MAIN-only; they need the stripped Domain/User, and the LB nginx does not route them) |
Public/Controllers/Api/AdminApiController.php, AdminAPIWrapper.php |
Full admin API removed from LB |
Public/Controllers/Api/ResellerRestApiController.php, ResellerAPIWrapper.php |
Reseller API removed from LB |
Public/Controllers/Api/ActiveCodeApiController.php |
Activation-code API (the LB nginx never routes active_code) |
Infrastructure/ResellerApiDispatcher.php, ResellerTableRenderer.php |
Reseller panel helpers |
config/rclone.conf |
Backup config |
Domain/Epg/EPG.php |
EPG processing class |
Core/Enum/Theme.php, ResellerAction.php, ClientFilter.php |
Panel-only enums |
bin/nginx/conf/gzip.conf |
Gzip config (LB uses own) |
The viewer-API controllers (PlayerApiController, Enigma2ApiController, XPluginApiController,
EpgApiController, PlaylistApiController and their BaseApiController) still ship, because
lb_configs/nginx.conf still routes /api/player_api and the other viewer endpoints to
Public/index.php. They are removed together with those routes in a later phase.
CLI commands removed:
| File | Reason |
|---|---|
Cli/Commands/MigrateCommand.php, Cli/migration_logic.php |
Migration is MAIN-only |
Cli/Commands/DbMigrateCommand.php |
Applies MAIN's schema migrations (MAIN-only) |
Cli/Commands/CacheHandlerCommand.php |
Cache handler is MAIN-only |
Cli/Commands/ServerInstallCommand.php |
Server installer (not needed on LB itself) |
Cli/Commands/ServerSyncOpensslExtraCommand.php |
Sends MAIN's OPENSSL_EXTRA to LBs (MAIN-only) |
Cli/Commands/LbInstallFlow.php |
LB install helper (not needed on LB itself) |
Cli/Commands/ProxyInstallFlow.php |
Proxy install helper (not needed on LB itself) |
Cron jobs removed:
| File | Reason |
|---|---|
Cli/CronJobs/RootMysqlCronJob.php |
DB maintenance (MAIN-only) |
Cli/CronJobs/BackupsCronJob.php |
Backups (MAIN-only) |
Cli/CronJobs/CacheEngineCronJob.php |
Full cache rebuild (MAIN-only) |
Cli/CronJobs/EpgCronJob.php |
EPG processing (MAIN-only) |
Cli/CronJobs/UpdateCronJob.php |
Update check (MAIN-only) |
Cli/CronJobs/ProvidersCronJob.php |
Provider sync (MAIN-only) |
Cli/CronJobs/SeriesCronJob.php |
Series metadata (MAIN-only) |
Note: Module-related crons (Plex, Watch) live inside
src/Modules/<name>/and are excluded from LB builds automatically —Modules/is not inLB_DIRS. The TMDB crons are not module crons:Cli/CronJobs/TmdbCronJob.phpandCli/CronJobs/TmdbPopularCronJob.phpare core jobs and ship in the LB archive.Ministra (
src/Ministra/, the Stalker portal — ~50 MB of assets) is likewise excluded by omission: it isn't listed inLB_DIRS, so it's never copied into the LB archive (there is no explicit removal rule for it — hence theministraabsence check in Build Verification below).
LB Build — Replaced Configs¶
These files from lb_configs/ replace the MAIN versions:
| Source | Target | Purpose |
|---|---|---|
lb_configs/nginx.conf |
bin/nginx/conf/nginx.conf |
Performance-tuned nginx for streaming |
lb_configs/live.conf |
bin/nginx_rtmp/conf/live.conf |
RTMP callback hooks |
LB Build — Deleted Files on Update¶
An update extracts the archive over the installed tree, so a file disappears from an installed LB
only when migrations/deleted_files.txt lists it (MigrationRunner::runFileCleanup() runs in
post-update). make lb therefore always writes the LB archive's list as the union of:
- the LB-scoped entries of
src/migrations/deleted_files.txt(paths underLB_DIRS, the retiredLB_RETIRED_DIRStreesresources/andwww/, or anLB_ROOT_FILESentry); - every file the LB build strips: the
LB_FILES_TO_REMOVEentries and the tracked files underLB_DIRS_TO_REMOVE, limited to the code trees (Cli/,Core/,Domain/,Infrastructure/,Public/,Streaming/).bin/,config/andcontent/hold runtime and per-server files and are never taken from the strip lists. The trees inLB_KEEP_ON_UPDATEare left out as well.
A file newly added to a strip list is thus also removed from LBs installed by an older release.
LB_KEEP_ON_UPDATE holds Domain/User. A fresh LB never gets it, but Public/stream/rtmp.php (the
RTMP on_play auth) and the viewer-API controllers still call it on routes the LB nginx serves. An
older LB that still carries it keeps it until those routes are removed.
The build verification fails if the list names a file that the LB archive ships (DELETES-SHIPPED).
MAIN vs LB — Key Differences¶
| Aspect | MAIN | LB |
|---|---|---|
| Admin panel | ✅ Full UI | ❌ Not included |
| Database role | Read + Write | Read-only consumer |
| User/device management | ✅ | ❌ |
| EPG processing | ✅ | ❌ |
| Backups | ✅ | ❌ |
| Migration tool | ✅ | ❌ |
| Stream delivery | ✅ | ✅ |
| RTMP ingestion | ✅ | ✅ |
| Transcoding (FFmpeg) | ✅ | ✅ |
| CLI commands | 26 | ~15 (admin-only removed) |
| Cron jobs | 25 | ~16 (admin-only removed) |
| Module system | ✅ | ❌ |
LB Nginx Configuration¶
The LB build uses a specialized nginx config optimized for high-throughput streaming:
| Setting | Value | Purpose |
|---|---|---|
| Worker processes | auto |
Scale to CPU cores |
| Worker connections | 16,000 | High concurrency per worker |
| Max file descriptors | 300,000 | System resource limit |
| Thread pool | pool_xc_vm (32 threads) |
Async I/O for streaming |
| Gzip | OFF | Streaming data is already compressed |
| Access logs | OFF | Reduce I/O overhead |
| Rate limiting | 20 req/s per IP | DDoS mitigation |
| Send timeout | 20 min | Support long-running streams |
RTMP hooks (lb_configs/live.conf) route authentication through local HTTP callbacks instead of the admin panel:
on_play http://127.0.0.1:8080/stream/rtmp;
on_publish http://127.0.0.1:8080/stream/rtmp;
on_play_done http://127.0.0.1:8080/stream/rtmp;
Runtime Behavior on LB¶
Command Discovery¶
console.php does not list commands. It globs Cli/Commands/*.php and Cli/CronJobs/*.php and
registers every concrete class that implements CommandInterface:
foreach (glob($rDir . '/*.php') as $rFile) {
$rClass = $rNamespace . basename($rFile, '.php');
if (!class_exists($rClass)) {
continue;
}
// ... register it if it is a concrete CommandInterface
}
A command or cron job that the LB build strips is simply absent on the LB, so it is never registered. No guard is needed.
Streaming Dependency Chain¶
LB servers retain the full streaming pipeline:
Public/stream/index.php (stream gateway) → Public/stream/<handler>.php
├── vendor/autoload.php (Composer PSR-4 autoloader)
├── Infrastructure/Bootstrap/StreamingRequestBootstrap.php
├── Core/* (Config, Database, Cache, Auth, Http, Logging, Util)
├── Domain/Stream, Domain/Server, Domain/Vod, Domain/Bouquet
├── Streaming/* (Auth, Delivery, Codec, Protection)
└── Infrastructure/Redis, Infrastructure/Database
Adding New Code to Builds¶
New streaming-relevant directory under src/¶
Add it to LB_DIRS in the Makefile:
LB_DIRS := bin Cli config content Core Domain \
Infrastructure Public signals Streaming tmp vendor your_dir
New admin-only directory¶
Add it to LB_DIRS_TO_REMOVE:
New admin-only file¶
Add it to LB_FILES_TO_REMOVE:
New CLI command (admin-only)¶
Add the file to LB_FILES_TO_REMOVE. If it is privileged, also add it to SENSITIVE in
tools/ci/verify-lb-archive.sh. console.php discovers commands by glob, so it needs no change.
Build Verification¶
make gates runs tools/ci/verify-lb-archive.sh, which rebuilds the LB file list from the
Makefile variables (no tarball needed) and fails on:
| Finding | Meaning |
|---|---|
STALE |
An LB_DIRS / LB_ROOT_FILES / LB_DIRS_TO_REMOVE / LB_FILES_TO_REMOVE / LB_KEEP_ON_UPDATE entry matches no tracked path under src/. A renamed or removed path would otherwise turn its rule into a silent no-op. |
WRONG-LIST |
An entry is the wrong kind for its list: a file in LB_DIRS, LB_DIRS_TO_REMOVE or LB_KEEP_ON_UPDATE, or a directory in LB_ROOT_FILES or LB_FILES_TO_REMOVE. The build strips a LB_DIRS_TO_REMOVE path with rm -rf, so a file there is stripped. rm -f and cp skip a directory, so a directory in a file list does nothing. |
LEAK |
A privileged path from the script's SENSITIVE list would ship to the LB. |
MISSING |
A file that the LB nginx routes to is stripped. The script checks every SCRIPT_FILENAME and Public/<scope>/<handler>.php for each handler the /stream/ and /admin/ gateway locations accept. It also checks the controller that Public/index.php dispatches for each XC_API value (such as internal for /api), plus BaseApiController, StreamingRequestBootstrap and WebApiBootstrap. An XC_API value that Public/index.php does not map also fails. |
DELETES-SHIPPED |
The LB migrations/deleted_files.txt built by make lb_delete_files_list names a file that the LB archive ships, so the update would delete it from every LB. |
When you strip a new file, add it to LB_FILES_TO_REMOVE (it must be a tracked file) and, if it is
privileged, to SENSITIVE in tools/ci/verify-lb-archive.sh. When you strip a routed handler, also
remove its route from lb_configs/nginx.conf.
After modifying the build, verify both variants:
# Build both
make new
# Check LB contains streaming code
tar -tzf dist/loadbalancer.tar.gz | grep -cE "Core/|Domain/Stream|Streaming/"
# Expected: > 0
# Check LB does NOT contain admin code
tar -tzf dist/loadbalancer.tar.gz | grep -cE "admin/|player/|ministra|reseller"
# Expected: 0
# Compare sizes (LB should be significantly smaller)
ls -lh dist/xc_vm.tar.gz dist/loadbalancer.tar.gz
Nightly Builds (Dev Channel)¶
.github/workflows/build-dev.yml publishes nightly builds of main for panels on the Dev update channel. It runs at 02:00 UTC and on manual dispatch (force rebuilds an unchanged main). It skips the build when main is unchanged since the previous nightly or has no commits since the last release.
Nightly builds go to the releases-only repository Vateron-Media/XC_VM_Dev, not to XC_VM, for three reasons:
- The panel reads one page of up to 100 releases. Daily releases in
XC_VMwould push the stable ones off that page. - The release workflows take the newest tag as
LAST_TAG, and nightly tags would take its place. - Every release in
XC_VMnotifies the repository's watchers andrelease-notifier.yml.
Version. Each build is tagged <base>-dev.<run number>. <base> is XC_VM_VERSION from the source when it is already bumped past the last X.Y.Z tag, otherwise the next patch after that tag. The workflow runs the unit tests first, then stamps the version into ConstantsInitializer.php and runs make lb and make main with LAST_TAG set to the last release. deleted_files.txt therefore covers every deletion since that release, so a panel that skips nightlies still gets a correct cleanup.
Assets. A nightly carries the same assets as a release (xc_vm.tar.gz, loadbalancer.tar.gz, XC_VM.zip, hashes.md5) plus changelog.json, which is built from the commit subjects since the previous nightly. XC_VM_Dev has no source tree, so GitHubReleases::getChangelog() reads nightly changelogs from this asset, not from the tagged changelog.json. The first line of the release notes (Source: …/commit/<sha>) records the source commit, and the next run compares against it. Only the newest 20 nightlies are kept.
Panel side. UpdateChannels::mainReleases() gives the MAIN release client GIT_REPO_DEV. On the dev channel GitHubReleases merges both repositories' releases, orders them with version_compare(), and fetches each asset from the repository that published its tag (GitHubReleases::isDevVersion()). If XC_VM_Dev cannot be reached, the dev channel still receives regular releases.
Setup. The workflow publishes with a fine-grained personal access token stored in the repository secret DEV_RELEASE_TOKEN. Scope the token to XC_VM_Dev only, with Contents: read and write. When the token expires, the nightly fails at its first gh call; issue a new token and update the secret. XC_VM_Dev must be public, because panels download from it without a token, and it needs at least one commit for release tags to point at.