Download the PHP package detain/phlix-server without Composer

On this page you can find all versions of the php package detain/phlix-server. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package phlix-server

Phlix Media Server

PHPUnit Coding Standards Web UI codecov PHP PHPStan Code style

A comprehensive media server platform built with PHP 8.3+, featuring real-time WebSocket communication, HTTP REST APIs, and support for multiple client platforms including Roku, Samsung Tizen, and Windows.

Repository moved 2026-05-17: this codebase migrated from github.com/detain/phlix to github.com/detain/phlix-server as part of the Phase B repo split (see PHLIX_EXPANSION_PLAN.md). Update existing local clones with git remote set-url origin [email protected]:detain/phlix-server.git. The old repo is being archived in step B.4b.

Overview

Phlix Media Server provides a complete media management and streaming solution:

Architecture

Requirements

Features

Foundation

Web Portal

Authentication & Security

SyncPlay - Group Watching

Session Management

Live TV & DVR

Installation

One-line install (Ubuntu/Debian)

On a fresh Ubuntu/Debian host, scripts/install.sh does the whole thing: system packages (PHP 8.3+, MySQL, ffmpeg), a dedicated phlix system user, MySQL database + user, application code, env file at /etc/phlix/env, generated PHLIX_SECRET_KEY, database migrations, a systemd phlix-server service, and an HAProxy reverse proxy with an auto-renewing Let's Encrypt certificate.

The installer also compiles the Swoole + php-uv extensions from source (the coroutine runtime Workerman uses), idempotently skipping the build when they already load, and runs a disable_functions preflight — see Swoole & php-uv on Linux.

Provision HTTPS in the same run by passing your domain and a Let's Encrypt contact email:

The script prompts for the install path, database user/password, and hostname when run in a terminal (with sensible defaults), and runs fully unattended when piped or given -y. Run sudo bash scripts/install.sh --help for every flag. Default ports: HTTP on :8096 behind HAProxy on :80/:443; DLNA discovery on 1900/udp.

Install flags

sudo bash scripts/install.sh --help lists every option. The most useful:

Flag Effect
--domain HOST Public hostname for the server (enables TLS when paired with --admin-email)
--admin-email EMAIL Email registered with Let's Encrypt
--db-name, --db-user, --db-pass, --db-host, --db-port MySQL identity (random password if --db-pass omitted). Note: config/database.php hardcodes host/port/db/user; only the password is env-driven.
--http-port PORT HTTP listen port (default 8096)
--tmdb-api-key KEY TMDB API key for metadata (optional, recorded in /etc/phlix/env)
--hub-url URL PHLIX_HUB_URL for hub relay (optional)
--service-user USER System user to run as (default phlix — dedicated system account, created if missing)
--branch NAME Git branch or tag to install (default master)
--repo URL Git repository URL (default detain/phlix-server)
--tls / --no-tls Force or skip Let's Encrypt + HAProxy TLS
--no-proxy Skip the managed HAProxy entirely (use your own reverse proxy)
--update Pull new code + run migrations on an existing install (preserves env + secrets)
--uninstall Remove the install — interactive prompts before each destructive step
--purge With --uninstall, also drop the DB, delete the Let's Encrypt cert, wipe /var/phlix, and remove the dedicated system user
-y, --non-interactive Never prompt; use defaults/flags
--interactive Force prompts even when piped

Updating an existing install

The same scripts/install.sh updates an in-place install without rotating any secrets. It reads the existing /etc/phlix/env (so DB_PASSWORD and PHLIX_SECRET_KEY are preserved), pulls the latest code, refreshes Composer dependencies, runs migrations, and restarts the service:

Or via the one-liner, which works regardless of where the server was installed because --update discovers the install path from the systemd unit. This is the exact string the admin console's update banner offers for copy-to-clipboard (config/updates.php's update_command, surfaced by GET /api/v1/admin/updates/status) — the server never runs it for you:

Pin to a specific tag or branch with --branch:

--update discovers the install path from the systemd unit's WorkingDirectory, fetches code as the install dir owner (so it doesn't trip Git's CVE-2022-24765 dubious-ownership check), runs composer install --no-dev --optimize-autoloader, clears templates_c/, runs scripts/run-migrations.php, restarts phlix-server, and curl-checks /health. It deliberately leaves the env file, MySQL grants, HAProxy config, and Let's Encrypt cert alone.

Uninstalling

scripts/install.sh --uninstall removes an existing install. It is interactive by default and prompts separately before each destructive step. The MySQL database, the /var/phlix data directory, and the Let's Encrypt certificate are kept unless you opt in:

Add --purge to also drop the database (and user), wipe /var/phlix (config, library cache, backups), and delete the Let's Encrypt certificate via certbot delete. Combine with -y for a fully unattended teardown:

What it removes when present:

  1. The phlix-server systemd unit (stop, disable, remove file, daemon-reload).
  2. HAProxy fragment at /etc/haproxy/phlix-managed/phlix-server.cfg.fragment, and /etc/haproxy/haproxy.cfg is rebuilt. If phlix-hub is still installed, its frontend + backend stay. If phlix-server was the last Phlix project, the pre-Phlix snapshot at /etc/haproxy/haproxy.cfg.pre-phlix.bak is restored (or haproxy.cfg is removed and haproxy is stopped + disabled if no snapshot exists).
  3. The combined PEM at /etc/haproxy/certs/<domain>.pem.
  4. /etc/cron.d/phlix-server-certbot and the certbot deploy hook.
  5. The Let's Encrypt cert via certbot delete — only with --purge or interactive confirm.
  6. The MySQL database + user — only with --purge or interactive confirm.
  7. The install dir (/var/www/phlix by default; system paths refused).
  8. /var/phlix (config, library cache, backups) — only with --purge or interactive confirm.
  9. /var/log/phlix and /var/run/phlix.
  10. /etc/phlix/env (env file).
  11. The dedicated system user phlix via userdel — only with --purge or interactive confirm. Refuses to touch shared OS accounts (www-data, root, etc.). Cross-detects phlix-hub's systemd unit and refuses to remove a user that's still being used by it.

System packages (php-*, mysql-server, ffmpeg, haproxy, certbot) and ufw rules are left in place — sudo apt remove … / sudo ufw delete … to remove them.

Running alongside phlix-hub on the same server

Both installers can share a single HAProxy instance — they auto-merge into one /etc/haproxy/haproxy.cfg. Just run both installers normally; the second one detects the first's fragment and rebuilds a combined config that routes by Host: header.

After both finish, /etc/haproxy/haproxy.cfg looks like:

How the merge works. Each install drops a fragment at /etc/haproxy/phlix-managed/<project>.cfg.fragment with fe_http, fe_https, and backends sections. A rebuilder function then assembles the final haproxy.cfg from every fragment it finds. HAProxy's crt /etc/haproxy/certs/ directive auto-loads every .pem in that directory and picks the right one per SNI hostname.

The first install snapshots any pre-Phlix haproxy.cfg to /etc/haproxy/haproxy.cfg.pre-phlix.bak.

Uninstall behaviour: --uninstall removes only that project's fragment and rebuilds. If other Phlix projects remain, their frontend stays untouched. When the last Phlix project is uninstalled, the rebuilder restores the pre-Phlix snapshot (or removes haproxy.cfg outright if there was no pre-Phlix config) and stops/disables haproxy.

The hub server-tunnel port (:8802) is a separate listener — servers connect to that port directly. Open it on the firewall but don't put it behind the HAProxy 80/443 frontend.

If you'd rather use your own reverse proxy (nginx, Caddy, Traefik, etc.) instead of the managed HAProxy, pass --no-proxy to either install script. Each service then listens on its own port (8096 for phlix-server, 8800 for phlix-hub) and you point your proxy at those.

Everything else is already namespaced: env files (/etc/phlix-hub.env vs /etc/phlix/env), systemd units (phlix-hub.service vs phlix-server.service), install dirs (/opt/phlix-hub vs /var/www/phlix), service users (www-data vs phlix), MySQL DBs (phlix_hub vs phlix), backend ports (8800/8802/8803 vs 8096), and certbot artefacts.

Manual install (from source)

Configuration

Configuration is managed via PHP files in config/:

Environment variables

Most operators only set DB_PASSWORD and JWT_SECRET; the following knobs cover the streaming and auth-hardening features and all have safe defaults.

Variable Default Description
HLS_MIN_DISK_SPACE_BYTES 524288000 (500 MiB) Free-space floor for the HLS segment-cache directory (config/server.php → hls.min_disk_space_bytes). When free space drops below this, the server sweeps the cache and returns 503 with Retry-After: 3 instead of failing an encode with ENOSPC.
RATE_LIMIT_<SURFACE>_MAX per-surface default Max attempts per window for an auth surface before it rate-limits. <SURFACE> is one of REGISTER, REFRESH, WEBAUTHN_START, WEBAUTHN_FINISH, JWKS, WS_CONNECT (defaults: register 5, refresh 30, webauthn_start/finish 10, jwks 120, ws_connect 30). See config/server.php → rate_limit.
RATE_LIMIT_<SURFACE>_WINDOW per-surface default Window length in seconds for the matching surface (defaults: register 600, refresh 60, webauthn_start/finish 60, jwks 60, ws_connect 60).
TRUSTED_PROXIES loopback only (127.0.0.1, ::1) Comma-separated IP/CIDR list of reverse-proxy hops. Used to derive the real client IP from X-Forwarded-For/X-Real-IP for rate-limit keys. This must reflect your nginx/HAProxy hops — if a non-loopback proxy fronts the server and is not listed here, IP-keyed limits will bucket every request under the proxy address (or trust a client-forged header). The stock install fronts Phlix over loopback, so the default is correct there.
PHLIX_DOMAIN unset This server's public authority — host or host:port, no scheme and no path (e.g. media.example.com, media.example.com:8443). scripts/install.sh --domain <domain> writes it to the env file the systemd unit reads. Two uses: it is the base domain config/hub.php composes into hub.domain/hub.public_url, and it is the allowlist for deriving the OAuth2/OIDC redirect_uri — /auth/{oidc,github}/authorize builds <scheme>://<Host><callback path> only when the request's Host (port included, with the scheme's default :443/:80 normalised away) equals this value. Unset or malformed ⇒ nothing is derived and the flow fails closed with 503 callback_url_not_configured; a garbage value (a full URL, a trailing path or ., an out-of-range or non-numeric port, whitespace) is treated as unconfigured, never as an allowlist that cannot match. The escape hatch is a per-provider absolute redirect_uri setting, which keeps first priority and works with PHLIX_DOMAIN unset.
PHLIX_DEBUG_EVENTS 0 (off) Diagnostic toggle. When truthy (1/true/yes/on) it (1) wraps the PSR-14 dispatcher in a debug decorator that logs every dispatched event class, and (2) enables the events log handler so those records are written to .logs/events.log. When off, events.log stays empty and no per-event debug logging happens. Leave off in production. events.log only ever receives EVENTS-channel records; plugins.log only PLUGINS-channel records; app.log/error.log still capture everything / all errors. See config/logger.php.

Rate limiting. The auth surfaces above (register / refresh / WebAuthn start+finish / public JWKS / WS-connect on :8097) are rate-limited. HTTP surfaces reply 429 Too Many Requests + Retry-After with body {"error":"Too Many Requests","code":"rate_limited"}; WS-connect rejects the handshake. login keeps its own DB-backed IP limiter (migration 074).

Loudness normalization. config/ffmpeg.php → loudness (disabled by default) applies an EBU R128 loudnorm filter to re-encoded audio when enabled. Copy-audio rungs (the original variant) and direct-play sessions are not normalized — you cannot filter a copied (non-decoded) stream.

Migration required on deploy. SV-4.15 adds migrations/085_rate_limit_buckets.sql (the shared DB-backed rate-limit bucket table). Run php bin/phlix migrate (or php scripts/run-migrations.php) after pulling — migrations are idempotent, so re-running is safe.

No one-time cleanup needed for media_items any more (S152). migrations/096_path_hash_unique_index.sql adds UNIQUE KEY idx_media_items_library_path_hash (library_id, path_hash) as part of the ordinary migration chain, so a fresh install gets the path-dedupe constraint from php bin/phlix migrate alone. Before it, migration 072 deferred the index to migrations/cleanup_072.php and migration 087 dropped it, so an install nobody hand-finalized had no duplicate-path protection — and every scanner path lookup lost the const plan. The migration is idempotent and refuses to touch a table that still holds duplicates, failing instead with a message that names the remedy: run php migrations/cleanup_072.php once to merge them, then re-run migrations. That script now owns de-duplication only.

No one-time cleanup needed for playback_state any more (S156). migrations/097_playback_state_unique_key.sql adds UNIQUE KEY uq_playback_state_session_media (session_id, media_item_id) as part of the ordinary migration chain, so a fresh install gets the progress-upsert constraint from php bin/phlix migrate (or php scripts/run-migrations.php) alone. Before it, migration 090 (S29, updates.md #29) reserved the number but carried no executable statement at all and deferred the key to the manual migrations/cleanup_090.php, which neither scripts/install.sh nor the Docker entrypoint nor either migrate command ever calls — so an install nobody hand-finalized had no (session_id, media_item_id) constraint, the ON DUPLICATE KEY UPDATE in PlaybackController::reportProgress() / StreamManager::persistStreamState() could never fire, and every ~15 s progress tick inserted a new row instead of updating one (finished episodes never left Continue Watching and playback_state grew without bound). The migration is idempotent and refuses to touch a table that still holds duplicate rows, failing instead with a message that names the remedy: run php migrations/cleanup_090.php once to merge them, then re-run migrations. That script now owns de-duplication only — it is needed only on an existing install that accumulated duplicate rows while the key was missing, never on a fresh one.

Migration AND rescan required on deploy (S331). Music albums with no artist tag are no longer discarded whole — they are ingested under a structural placeholder artist whose display label is [Unknown Artist]. migrations/102_music_artists_placeholder_flag.sql adds music_artists.is_placeholder and widens uk_name to (name, is_placeholder), so a REAL album genuinely tagged [Unknown Artist] stays a distinct artist (is_placeholder = 0) from the untagged bucket (is_placeholder = 1). Run php bin/phlix migrate (or php scripts/run-migrations.php) after pulling — migrations are idempotent, so re-running is safe. ⚠ A scan (or rescan) of each music library is ALSO required: albums the old policy discarded were never written to the database, and no backfill can reconstruct them without re-reading the audio files — the migration itself only adds the column and touches no rows. The scan summary key skipped_no_artist is renamed placeholder_artist_files (untagged albums are now indexed, not skipped).

API Reference

HTTP Endpoints

Method Path Description
GET /health Health check
GET /system/info Server information
POST /api/v1/auth/register User registration
POST /api/v1/auth/login User login
POST /api/v1/auth/refresh Token refresh
GET /api/v1/auth/me Current user profile
GET /api/v1/sessions List user sessions
DELETE /api/v1/sessions/{id} End a session
POST /api/v1/sessions/{id}/progress Report playback progress
GET /api/v1/sessions/{id}/progress Get playback state
POST /api/v1/sessions/{id}/complete Signal that playback finished — body {media_item_id: string (required), reached_end?: bool = true}; reached_end:true marks the item watched (leaves Continue Watching, finalizes watch-time/stats), false clears the resume point. Auth mirrors /progress (session owner). → {message, reached_end}; 404 unknown session, 403 wrong owner, 400 missing media_item_id. The web SPA player + mini-player POST this on the media ended event; native clients do not call it yet
GET /api/v1/users/me/continue-watching (also /api/v1/me/continue-watching) Continue-Watching list for the authenticated user — {items:[...]} of MediaItemShaper-shaped rows with top-level id (= media item id, for detail navigation), poster_url/poster_srcset (series poster for episodes, falling back season → own), runtime (minutes), year, rating, plus re-attached position_ticks/duration_ticks (SPA resume) and preserved media_item_id, parent_id, metadata (console/rating-gate compatibility)
GET /api/v1/users/me/next-up "Next Up" rail — for each series the active profile has STARTED, the single next unwatched episode to play (sibling to Continue Watching). Optional ?limit= (default 20, clamped 1-50; the server scans up to max(limit×3, 50) started series). Auth required (AuthMiddleware). Returns {items:[...]} of MediaItemShaper-shaped episode rows (series poster resolved) with position_ticks/duration_ticks=0 plus added series_id/series_name; media_item_id carried for the rating-gate post-filter. Watched/in-progress signal is playback_state ONLY (not user_item_data.watched / watch_history); only numbered seasons are walked (Specials excluded); all-watched series yield no entry. 401 unauth, 503 if watch-history unwired, {items:[]} when no active profile
GET /api/v1/media/facets Distinct, sorted genre facet list for the media filter UI (?libraryId=<uuid> to scope) → {genres: string[]}
GET /api/v1/media/most-watched GLOBAL "Most Watched" trending rail — the media items most-watched across the WHOLE server (not per-user), reusing the same all-time cross-user aggregate as the admin Top Media report, ordered by play count. Optional ?limit= (default 20, clamped to 100). Auth required (AuthMiddleware, same audience as GET /api/v1/media). Returns {items, total, limit, offset} with MediaItemShaper-shaped items (poster/artwork signed URLs re-minted at response time)
GET /api/v1/media/{id} Media item detail — includes user_data: {favorite, rating, like_level} when authenticated (null otherwise)
POST /api/v1/media/{id}/favorite Mark a media item as the user's favorite — auth required
DELETE /api/v1/media/{id}/favorite Remove a media item from the user's favorites — auth required
PUT /api/v1/media/{id}/rating Set the user's personal rating (body {rating: int 1-10\|null}; null clears) — auth required
DELETE /api/v1/media/{id}/rating Clear the user's personal rating — auth required
PUT /api/v1/media/{id}/like Set the user's thumbs value (body {level: int -2..2}, required; -2=strongly dislike, -1=dislike, 0=not set, 1=like, 2=love) — auth required
GET /api/v1/users/me/favorites List the user's favorited items (shaped media items + user_data incl. like_level; ?limit=1-100&offset) — auth required
GET /api/v1/admin/settings Effective server settings (config default + DB override) — admin-only
PUT /api/v1/admin/settings Persist server-setting overrides — admin-only
GET /api/v1/admin/fs/browse List subdirectories under allowed roots (library path picker) — admin-only
GET /api/v1/admin/users List all users — admin-only
GET /api/v1/admin/users/{id} Get a specific user — admin-only
POST /api/v1/admin/users Create a new user — admin-only
PUT /api/v1/admin/users/{id} Update a user (username, email, password) — admin-only
DELETE /api/v1/admin/users/{id} Delete a user — admin-only
POST /api/v1/admin/users/{id}/set-admin Promote or demote a user — admin-only
POST /api/v1/admin/users/{id}/reset-password Reset a user's password (returns new password) — admin-only
GET /auth/oidc/authorize · /auth/github/authorize Start an external login (unauthenticated). Redirects to the IdP / GitHub with PKCE + state, sets the browser-binding correlation cookie, and stores the resolved absolute redirect_uri in the server-side state. 503 provider_not_configured when the provider is not enabled+configured; 503 callback_url_not_configured when no absolute callback URL can be resolved (see PHLIX_DOMAIN)
GET /auth/oidc/callback · /auth/github/callback Provider redirect target (unauthenticated). One-shot state consume, correlation-cookie check (403 on mismatch), token exchange with the same redirect_uri replayed from state, then session cookies + a 302 to the same-origin redirect_uri. Also handles the account-link branch
GET /auth/identities List the current user's linked external identities — auth required
GET /auth/identities/link/oidc · /auth/identities/link/github Start a verified OAuth link onto the current account — auth required
POST /auth/identities/link/ldap Link an LDAP identity via a live bind (body {username, password}) — auth required
DELETE /auth/identities/{id} Unlink one of the current user's identities (404 if not yours, 409 last_sign_in_method if it is your last) — auth required
GET /api/v1/admin/auth-providers List registered auth providers — admin-only
POST /api/v1/admin/auth-providers/{name}/enable | /disable Enable/disable oidc, ldap or github (409 not_configured if it has no saved config) — admin-only
GET/POST /api/v1/admin/auth-providers/oidc/config Read / save OIDC config (provider_url, client_id, client_secret write-only, scopes, redirect_uri). An absent optional key is preserved, not cleared — admin-only
GET/POST /api/v1/admin/auth-providers/ldap/config Read / save LDAP config; POST .../ldap/test dry-runs a bind — admin-only
GET/POST /api/v1/admin/auth-providers/github/config Read / save GitHub config (client_id, client_secret write-only, scopes, redirect_uri); configured means id and secret are present. An absent optional key is preserved — admin-only
GET /api/v1/admin/auth-providers/{oidc,ldap,github}/schema JSON schema for the provider's config form — admin-only

Rate limiting. POST /auth/register, POST /auth/refresh, the WebAuthn login start/finish endpoints, and the public JWKS endpoint reply 429 Too Many Requests + Retry-After (body {"error":"Too Many Requests","code":"rate_limited"}) when a caller exceeds the per-surface limit. Tune via RATE_LIMIT_* (see Environment variables).

JWKS key format. GET /.well-known/jwks.json serves this server's Ed25519 public key from config/hub-server-key.pem. The reader accepts both the app's native -----BEGIN ED25519 PRIVATE KEY----- format and a standard PKCS#8 Ed25519 key (-----BEGIN PRIVATE KEY-----, e.g. from openssl genpkey -algorithm Ed25519). If the key cannot be loaded, the endpoint degrades to a valid empty keyset ({"keys":[]}, HTTP 200) and logs an error rather than returning a 500.

Client capability negotiation. Send an X-Phlix-Client-Capabilities request header (a JSON codec-support map, e.g. {"eac3":false}) on playback-info requests. When present, the direct_play verdict is set from whether the client can decode the item's audio codec; absent/empty header keeps the previous always-true behavior.

WebSocket Events

Connection Events:

Authentication Events:

Playback Events:

SyncPlay Events:

Development

Running Tests

Real-database integration tests self-skip. Tests under tests/Integration/ that need a live MySQL probe the configured host first and skip when it is unreachable, so ./vendor/bin/phpunit is green on a machine with no database — it just reports more skips. To actually exercise them, point the suite at a throwaway MySQL that has had php scripts/run-migrations.php applied:

CI does exactly this (.github/workflows/phpunit.yml provisions a mysql:8.0 service and runs the migrations before the suite), so these tests are not decorative. Note that the network-dependent group is excluded by phpunit.xml on purpose: Phlix\Plugins\OAuth2\OAuth2HttpClient (the live OAuth2 transport) is driven by a fake in every provider test and is effectively uncovered — closing that gap needs a live HTTP fixture.

Comparing skip sets between two runs — never compare the COUNT

phpunit.xml sets displayDetailsOnSkippedTests="true", so every run of this suite that loads that file and uses PHPUnit's default result printer — CI, local, --testsuite Unit, syncplay-e2e.yml — prints each skipped test BY NAME. Use the names, not the number:

Against a CI run (the script strips gh's job<TAB>step<TAB>timestamp prefix for you):

That is also how the mechanism is checked after a merge: master's own pre-S345 log exits 4 on this command (measured on run 32027697809), so an exit of 0 with a non-empty file is the before/after demonstration. The exit code is the whole contract — an empty set is never handed over silently:

exit meaning what to do
0 every run in the input passed the contract; stdout is the sorted set (zero lines is a valid answer for a run that skipped nothing) compare it
2 the input is not PHPUnit output at all — no result summary line anywhere — or a run opened by a banner never completed check what you piped in
3 some run's numbers do not add up: a run printed no list, or PHPUnit's list/entry format drifted do not compare; read the denominators on stderr
4 some run counted skips, printed no detail list, and testdox does not explain them restore displayDetailsOnSkippedTests="true", or find the invocation that did not load phpunit.xml
5 some run's OWN testdox skip glyphs (↩) prove it is a --testdox run and account for all its unnamed skips (the glyph-less suite skip included), or a run ended No tests executed! with nothing named re-run without --testdox; not a phpunit.xml fault
6 the sorted set could not be written in full (sort, or your redirect, failed) stdout is truncated; do not compare

Denominators always go to stderr, on every exit, so a set can be checked against the count PHPUnit itself printed. The script's header enumerates the same six codes with the exact input class that reaches each one.

The evaluation is per run, not per input: a multi-run log — the normal gh run view <id> --log shape — is segmented at PHPUnit's banners and terminating summary lines, and each run's arithmetic is judged on its own denominators. When any run fails, every failing run is named on stderr (ordinal, banner, that run's own numbers), no set is written, and the first failing run's exit code wins; the input-wide tallies still printed on stderr are audit-only and decide nothing. The old failure mode — one run's shortfall paid for by another run's surplus, netting to a code right for the sum and wrong for both runs — is closed, so a refusal now points at a specific run instead of the input as a whole.

⚠ --testdox is the one invocation this cannot cover, and it is a PHPUnit limitation, not a config mistake. PHPUnit constructs the default result printer for a testdox run with its $displayDetailsOnSkippedTests argument hardcoded false (the outputIsTestDox() branch of Facade::createResultPrinter() — the configuration value is not consulted there; vendor/phpunit/phpunit/src/TextUI/Output/Facade.php:204-221 in the pinned PHPUnit 10.5.64, which is the citation the script's own error message prints), so a --testdox run adds to Skipped: N and can never name those skips, with or without --display-skipped. No workflow passes --testdox any more (syncplay-e2e.yml dropped it in S345 for exactly this reason). If you add it back, or use it locally, the script exits 5 and says so — do not "fix" phpunit.xml in response; re-run without --testdox. Exit 5 is decided inside the run whose OWN glyphs prove it is a testdox run: any ↩ glyph that does not surplus the run's shortfall marks the run, and because the attribution uses the run's own Skipped: N rather than the glyph count, a SINGLE glyph is enough to carry the whole shortfall to exit 5 — the glyph-less suite skip (a testSuiteSkipped event produces no glyph) is accounted for exactly this way. Only a SURPLUS — more glyphs than the run's shortfall, which no real testdox run can produce because PHPUnit counts every glyph it prints in the same run's Skipped: N — attributes nothing to testdox: both causes are then named and phpunit.xml is not exonerated. A surplus can only come from other output sharing the log, which gh run view --log makes routine. Stray glyphs are attributed to NO run only when they fall outside every run's segment — before any PHPUnit banner, or after a run's terminating summary line, since the segment is flushed and closed at that line — so those can never reach exit 5 whatever their count. A stray glyph that lands INSIDE a run's segment is attributed to that run and can carry it to exit 5, because it is arithmetically identical to the genuine mixed testdox shape above. Exit 5 is also taken for a run that ends No tests executed! with nothing named: a whole-suite skip under --testdox prints exactly that (the suite skip is invisible), so a quietly-empty set would be a lie. The message says to verify the invocation, because a genuinely empty run (a typo'd --filter, an empty directory) prints a byte-identical shape and is refused the same way — see the KNOWN LIMITS note below.

⚠ The second exception is the assertion-escape-probe job. It runs PHPUnit from scripts/assertion-escape-audit.php, which captures the output into a PHP variable and only tests it for a marker — it never echoes it. So no log line and no name set is obtainable from that job, whatever phpunit.xml says. Nothing is wrong with its config; its output simply never leaves the process. Those are the only two exceptions: PHPUnit's --teamcity printer still emits the skipped-details list, and was checked.

Skipped test suites (a class-level #[Requires*], or markTestSkipped() in setUpBeforeClass()) are counted in the same Skipped: N but named under a different header, and a run in which every suite skipped prints No tests executed! with no Tests: line at all. The script parses that shape too and emits such an entry as SUITE:<name>.

Why names and not Skipped: N. The number drifts between runs on its own — this repo has legitimate environment-dependent skips, and this box reports ~259 where CI reports 3 — and a moved count cannot distinguish "a test started skipping" from "a conditionally-skipped test turned into a failure" from "a test disappeared". All three move it identically, and two of the three can leave it unmoved. comm over the name set separates them; the count cannot. scripts/skipped-test-names.sh writes only the sorted set to stdout (so comm/diff can eat it directly), puts its denominators on stderr, and fails loudly rather than printing an empty set — with one of the exit codes in the table above. Each code names the cause its own arithmetic supports, and never attributes more to a cause than that cause can carry — a wrong diagnosis would send the next reader to fix the wrong thing, and rounds 2 and 3 of the S345 review plus its test engineer each caught one doing exactly that before it shipped (the last was a clamp that trimmed a surplus of testdox glyphs down to the shortfall; it is removed rather than re-tuned, because a surplus cannot come from a real testdox run at all).

⚠ That is not a guarantee that the code is right for every input — the script's header carries a numbered KNOWN LIMITS block, re-derived for per-run evaluation, and this section is its summary. Two of the old input-wide limits are closed by the segmentation and are no longer true: a shortfall in one run can no longer be paid for by another run's surplus (the multi-run refusal above replaces the netting), and N stray glyphs beside a run that lost N names no longer read as one testdox run that skipped N (exit 5 needs the run's OWN glyphs). What remains, all measured on this version:

The script's header carries all six limits (three closed by S352, three still open, plus the refusal side-effect of limit 2 — the genuinely-empty run shape, listed first above), numbered, each with the input that reaches it, and enumerates every exit path with its input class; that header is the canonical statement of this contract, and this section is its summary.

Keep the suite hermetic w.r.t. the environment. PHLIX_DOMAIN changes real behaviour (it is the OAuth callback-URL allowlist — see Environment variables), so every test that depends on it sets it in setUp() and restores the ambient value in tearDown(), and the "no allowlist" cases pass '' explicitly instead of relying on the env being unset. Running the whole suite with and without PHLIX_DOMAIN exported yields identical per-test assertion counts; if you add a test that reads an env var, restore it.

Code Standards

This project follows PSR-12 coding standards and uses static analysis tools:

Both are CI gates (.github/workflows/coding-standards.yml). Psalm is configured by psalm.xml at errorLevel="5"; there is no psalm-baseline.xml and the workflow fails if one appears. --show-info=false matches what CI runs — without it Psalm additionally prints ~1400 informational suggestions that are not gated.

Psalm needs PHP >= 8.3.16. Psalm 6.x aborts at startup on anything older (Psalm requires a PHP version ">= 8.3.16"), which includes the 8.3.6 that some dev boxes ship. CI is unaffected — shivammathur/setup-php with php-version: '8.3' installs the newest 8.3 patch. If your local PHP is too old, run it in a container instead of upgrading the box:

The extension list is not optional and mirrors the extensions: list in the Psalm CI job: Psalm resolves classes and constants from the extensions that are actually loaded, so a bare php:8.3-cli reports 140 phantom errors (Swoole\Coroutine\Channel, Socket, AF_INET, ZipArchive, GdImage, FFI, …) that do not exist on a properly configured runner.

CLI

Administrative tasks are exposed through the bin/phlix command-line tool (built on webman/console / Symfony Console):

php bin/phlix migrate is the supported equivalent of php scripts/run-migrations.php — both delegate to the same Phlix\Common\Database\MigrationRunner service. Re-running is safe: applied files are recorded in a schema_migrations ledger and skipped while their checksum matches, and a file that is re-applied tolerates the replay (statements use IF NOT EXISTS, and duplicate-column/duplicate-key errors are downgraded to notes because MySQL 8 has no IF NOT EXISTS on ADD COLUMN/ADD INDEX). More commands are added in later steps.

Exit codes (S159). Both commands exit 0 when every statement applied or failed only with an idempotent "already applied" error, and 1 when at least one genuine, non-idempotent statement error was recorded. A failing run does not stop at the first error — later files are still attempted — but the failing file is deliberately left out of the ledger so the next run retries it. Consequences worth knowing:

⚠ "Idempotent" is a closed list, not "anything a replay raises". The squelch is decided on the MySQL error number, never on the exception text — the driver prefixes the whole failing statement to its message, so matching a phrase like already exists used to grep the migration's own SQL and reclassify hard failures as successes. The set is exactly four: 1050 table exists, 1060 duplicate column, 1061 duplicate key name, 1091 can't drop / check that column-key exists. Each names an object that can only be the one the failing statement was creating — a table in this schema, or a column/index, which are table-local.

Everything else fails the run, including several shapes a re-run can legitimately produce — write the migration so they never fire:

Shape MySQL Write this instead
Seed row inserted twice 1062 duplicate entry INSERT IGNORE / INSERT … ON DUPLICATE KEY UPDATE
ALTER TABLE … ADD PRIMARY KEY replayed 1068 multiple primary key defined guard it, or add the PK in CREATE TABLE
… CONSTRAINT <name> FOREIGN KEY / CHECK replayed 1826 / 3822 duplicate constraint name guard the ADD CONSTRAINT (check information_schema, or DROP … IF EXISTS first) and give every constraint a schema-unique name
DROP TABLE/CREATE TRIGGER/CREATE FUNCTION replayed 1051 / 1359 / 1304 use the IF EXISTS / IF NOT EXISTS form those statements accept

1062 in particular is deliberate: it is also what ADD UNIQUE raises when the existing data violates the new constraint, which is the most valuable failure a migration can report. 1068 names no object, so a replay and a genuine "a different primary key is already there" are indistinguishable.

1826/3822 were considered for the idempotent set and rejected — do not add them. Foreign-key and CHECK-constraint names are unique per schema, not per table, so the object that already holds the name can belong to a completely different table, and the statement that failed can be a CREATE TABLE that consequently created nothing. Squelching them let such a file exit 0 and enter schema_migrations, so it was never retried — worse than no squelch at all.

Exit codes: library:scan (S347). php bin/phlix library:scan <libraryId> (with or without --rescan) returns one of:

exit meaning
0 scan (or rescan) completed; every file was indexed
1 scan did not run — unknown library id, the manager threw, or an already-queued/running job refused the scan
3 scan completed but N file(s) could not be indexed (the lossy warning is on stderr; each file is named in .logs/error.log)
2 Symfony's INVALID (invalid input/usage) — deliberately NOT used, so "you typed the arguments wrong" and "your library lost files" never share a number

The same table is printed by php bin/phlix library:scan --help. 3 (not 2) is the "completed but lost files" code because 2 is Symfony's reserved invalid-input code — a wrapper must be able to tell a scan that never ran from one that lost files.

Admin SPA (admin-ui)

The admin console is a React + TypeScript + Vite single-page app. Its source lives in admin-ui/; the production build is emitted into public/assets/admin/ and committed to the repo, so the running Workerman server has no Node build dependency at runtime (it just serves the static shell + bundle). admin-ui/node_modules/ is gitignored.

The SPA mounts at /admin and /admin/*, served by AdminAppController (returns the built index.html shell; 503 if the bundle is missing) and gated by the existing AdminMiddleware — a non-admin (401/403) is redirected (302) to /login. Its typed ApiClient reuses the same JWT mechanism as public/assets/js/api-client.js (access_token/refresh_token in localStorage, Bearer header, single retry on 401 via POST /auth/refresh).

When you change anything under admin-ui/src/, re-run npm run build and commit the refreshed public/assets/admin/ bundle along with your source changes.

CI runs the SPA build + Vitest suite on any change under admin-ui/ via the Admin UI GitHub Actions workflow (.github/workflows/admin-ui.yml): npm ci → npm run build → npm run test on push/PR to master/main/develop (path-filtered, so PHP-only changes don't trigger it).

The first feature page on top of the scaffold is the Libraries page at /admin/libraries (step 1.1c): list / add / edit / delete libraries, a PathPicker driving the 0.6 GET /api/v1/admin/fs/browse endpoint, per-row Scan / Rescan buttons that hit the async 1.1b scan API (POST /api/v1/libraries/{id}/scan|rescan → 202 {job_id, status: "queued"}), live coarse lifecycle status by polling GET .../scan-status every 2 s (polling stops on completed / failed), and a per-library scan-history modal. No backend changes — the page consumes only contracts already shipped by 0.6 and 1.1b.

The Settings page at /admin/settings (step 1.3) renders all 15 server-setting keys across 8 tabbed groups (Transcoding, Metadata, Markers, Subtitles, Discovery, Trickplay, Newsletter, Port Forward). It consumes the 0.5 GET/PUT /api/v1/admin/settings contract — no new endpoints were added. Bool keys render as toggle switches; numeric keys render as number inputs with min/max constraints; tmdb.api_key renders as a password field with Show/Hide toggle. Overridden keys (DB-persisted vs. config-file default) display a "custom" badge. A sticky Save button fires PUT /api/v1/admin/settings; dirty-state gating keeps the button disabled when no fields have changed.

The Webhooks page at /admin/webhooks (step 1.4a) provides full CRUD for webhook subscriptions plus a per-webhook test trigger. It consumes the five endpoint contract in WebhookAdminController (GET list, POST create, PUT update, DELETE remove, POST test). The PUT /api/v1/admin/webhooks/{id} route, plus the backing WebhookDispatcher::update() and WebhookAdminController::update() PHP methods, were added in this step to support edit-in-place (the controller already had index/create/delete/test before 1.4a). The event multi-select shows 7 subscribable events grouped into 5 categories (Playback, Library, Downloads, Recordings, Alerts); webhook.test is internal-only. Secret is write-only — GET never returns it; the edit form shows an empty field with "(unchanged)" placeholder and omits secret from the PUT payload when blank, so the server retains the stored value. Coverage: 97.29% on webhooks.ts, 89.74% on WebhooksPage.tsx.

Full operator + contributor docs live in the phlix-docs site (docs/admin/integrations.md, docs/admin/server-settings.md, and docs/dev/admin-spa.md).

Git Workflow

  1. Create a feature branch: git checkout -b feature/my-feature
  2. Make changes and commit: git commit -am 'Add new feature'
  3. Push to remote: git push origin feature/my-feature
  4. Create Pull Request on GitHub
  5. After review, merge via squash-merge

Contributing

  1. Fork the repository
  2. Create your feature branch
  3. Ensure all tests pass (./vendor/bin/phpunit)
  4. Follow PSR-12 coding standards
  5. Submit a pull request

License

LICENSE for the full text.

MPL-2.0 is per-file weak copyleft. In short:

"Phlix" is a trademark — see TRADEMARK.md for what that does and doesn't restrict.

Third-party components

Phlix Server depends on two LGPL-3.0 libraries — Smarty (smarty/smarty) and Tukio (crell/tukio) — plus a number of MIT-licensed packages. MPL-2.0 lists the GPL family as Secondary Licenses (MPL-2.0 §1.12, §3.3), so those are compatible. If you redistribute Phlix Server, you must still meet each dependency's own terms; composer licenses lists them.

Support

For issues and feature requests, please use the GitHub issue tracker.


For detailed development documentation, see DEVELOPER.md.


All versions of phlix-server with dependencies

PHP Build Version
Package Version
Requires php Version >=8.3
ext-ldap Version *
crell/tukio Version ^2.0
detain/phlix-shared Version ^0.20.0
directorytree/ldaprecord Version ^3.0
justinrainbow/json-schema Version ^5.2
monolog/monolog Version ^3.0
php-di/php-di Version ^7.0
phpseclib/phpseclib Version ^3.0
psr/container Version ^2.0
psr/event-dispatcher Version ^1.0
smarty/smarty Version ^4.0
symfony/process Version ^7.0
symfony/yaml Version ^7.0
web-auth/webauthn-lib Version ^4.0
web-token/jwt-framework Version ^4.1
webman/console Version ^2.2
workerman/http-client Version ^3.1
workerman/mysql Version ^1.0
workerman/redis Version ^2.0
workerman/webman-framework Version ~2.2
workerman/workerman Version ~5.1
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package detain/phlix-server contains the following files

Loading the files please wait ...