detain / phlix-server
Phlix media server - streaming, library management, user auth, and SyncPlay coordination
Requires
- php: >=8.3
- ext-ldap: *
- crell/tukio: ^2.0
- detain/phlix-shared: ^0.20.0
- directorytree/ldaprecord: ^3.0
- justinrainbow/json-schema: ^5.2
- monolog/monolog: ^3.0
- php-di/php-di: ^7.0
- phpseclib/phpseclib: ^3.0
- psr/container: ^2.0
- psr/event-dispatcher: ^1.0
- smarty/smarty: ^4.0
- symfony/process: ^7.0
- symfony/yaml: ^7.0
- web-auth/webauthn-lib: ^4.0
- web-token/jwt-framework: ^4.1
- webman/console: ^2.2
- workerman/http-client: ^3.1
- workerman/mysql: ^1.0
- workerman/redis: ^2.0
- workerman/webman-framework: ~2.2
- workerman/workerman: ~5.1
Requires (Dev)
- codeception/codeception: ^5.3
- codeception/module-asserts: ^3.2
- mockery/mockery: ^1.6
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.0
- squizlabs/php_codesniffer: ^3.10
This package is auto-updated.
Last update: 2026-07-25 08:06:13 UTC
README
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/phlixtogithub.com/detain/phlix-serveras part of the Phase B repo split (seePHLIX_EXPANSION_PLAN.md). Update existing local clones withgit remote set-url origin git@github.com: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:
- Media Library Management: Organize and browse media collections with automatic scanning
- User Authentication: JWT-based auth with refresh tokens
- Real-time SyncPlay: Watch content together with friends
- Live TV Support: DVR and guide integration
- DLNA Streaming: Standard protocol support for compatible devices
- Transcoding: On-the-fly media conversion via FFmpeg with automatic quality selection
- HLS Streaming: Adaptive bitrate streaming for web clients with multi-quality playlists. Transcoded
output is normalized for browser playback: 8-bit H.264 video, stereo AAC audio (surround layouts like
AC-3 5.1(side) are downmixed — a
channel_configuration=0PCE otherwise breaks hls.js), and even scale widths (force_divisible_by=2) - WebSocket Events: Real-time progress and notification delivery
- Multi-Source Metadata: Automatic metadata fetching from TMDB (movies), TVDB (TV series), Fanart.tv (artwork), and local NFO files with 24-hour cache and provider fallback
- Content Filtering: Parental controls with rating and genre-based filtering
Architecture
src/
├── Server/
│ ├── Core/ # Application bootstrap and core
│ ├── Http/ # HTTP REST API layer
│ │ ├── Controllers/ # Request handlers
│ │ ├── Request.php # HTTP request representation
│ │ ├── Response.php # HTTP response builder
│ │ └── Router.php # Route dispatching
│ ├── WebSocket/ # Real-time communication
│ │ ├── Connection.php # Client connection wrapper
│ │ ├── ConnectionPool.php # Connection management
│ │ ├── MessageHandler.php # Event routing
│ │ ├── WebSocketServer.php # Server implementation
│ │ └── Events.php # Event type constants
│ └── WebPortal/ # Web portal
│ ├── WebPortalRouter.php # REST API for the /app Vue SPA (JSON only)
│ └── Controllers/SharedUiController.php # serves the /app SPA shell (+ ViteAssets.php)
├── Session/ # Playback session management
├── Media/ # Media library and metadata
│ ├── Library/ # Library management (LibraryManager, ItemRepository, MediaScanner)
│ ├── Metadata/ # Metadata fetching (TMDB, TVDB, Fanart, NFO providers)
│ ├── Transcoding/ # FFmpeg transcoding (FfmpegRunner, TranscodeManager, EncodeSettings)
│ └── Streaming/ # HLS streaming with adaptive bitrate
├── Auth/ # Authentication services
└── Common/ # Shared utilities
public/
├── index.php # Web portal entry point (redirects legacy pages to /app)
├── templates/emails/ # Smarty newsletter email template (only remaining .tpl)
└── assets/ # Static assets (css, js) + assets/app/ (built Vue SPA bundle)
Requirements
- PHP: 8.3 or higher
- MySQL: 8.0+ or MariaDB 10.6+
- Workerman: 5.0+ (bundled via Composer)
- FFmpeg: For transcoding (optional)
Features
Foundation
- PSR-11 DI container (PHP-DI 7): auto-wired services with provider-based composition; see phlix-docs / dev / architecture-server.
- PSR-14 event dispatcher (Tukio): playback, library-scan, and auth
lifecycle events with typed
readonlyDTOs. Plugins subscribe by event class FQCN; see phlix-docs / dev / event-reference. - Plugin system: install / enable / disable / uninstall lifecycle,
sandboxed per-plugin
vendor/directories, signature-checked manifests, and PSR-14 event subscription viaPhlix\Shared\Plugin\LifecycleInterface(thePhlix\Plugins\Contract\LifecycleInterfaceFQCN remains a deprecated bridge through 0.11.x). Plugin developer guide: phlix-docs / plugins / developer-guide. Server-internals reference for contributors extending the loader: phlix-docs / dev / plugin-sdk. Reference plugin:detain/phlix-plugin-example. - Shared interfaces / DTOs in
detain/phlix-shared: framework-neutral Composer package shared withphlix-hub.Phlix\Shared\Plugin\*,Phlix\Shared\Events\*,Phlix\Shared\Auth\JwtClaims,Phlix\Shared\Metadata\MetadataSourceInterface(the typed metadata-source plugin contract, sincephlix-shared0.15.0), andPhlix\Shared\Hub\*DTOs live there sincephlix-server0.11.0. - Library matching & de-duplication: filename→title cleaning strips
multi-word noise suffixes (
Directors Cut,YIFY, …) before metadata matching (admin-tunable viamatching.noise_suffixes); a canonical-key resolver prevents duplicate top-level series/movies at scan time; and the admin Duplicates page +scripts/dedup-series.phpmerge historical duplicates. Metadata source order is configurable per media type via themetadata.provider_prioritysetting. See phlix-docs / admin / library-management and phlix-docs / admin / server-settings.
Web Portal
- Vue SPA (
/app) — the ONLY web UI: The web UI is the shared@phlix/uiVue SPA, served at/app(SPA shell viaSharedUiController+ViteAssets; built bundle underpublic/assets/app/). Routes + nav are registered inweb-ui/src/main.ts, covering Browse/Library, Player, Music sub-pages (/app/music/*), Books (/app/books/*), Audiobooks (/app/audiobooks/*), Photos (/app/photo/*), Search (/app/search), Settings (incl. the Security/passkey tab at/app/settings/security), and Admin (/app/admin/*). - Legacy Smarty page rendering removed: The old server-rendered (Smarty) portal pages —
PageRenderer, the page controllers, and allpublic/templates/**/*.tplpage templates — have been deleted. Their old paths now 302-redirect to the/appequivalent (e.g./login→/app/login,/library→/app,/player/{id}→/app/player/{id},/music|/books|/audiobooks|/photo→ their/apppages), dispatched inpublic/index.php. Smarty (smarty/smarty) is retained on the server only for the newsletter email (src/Admin/NewsletterGenerator.php+public/templates/emails/newsletter.tpl). - REST API Endpoints: Complete API for library browsing, media info, and user data
- JWT Authentication: Integrated token-based auth with refresh support
- Responsive Design: CSS-first approach with utility classes
- JavaScript Client: ApiClient helper with auth, library, and player helpers
- Continue Watching: Track and display in-progress media
- Library Browser: Browse media by library with item counts
Authentication & Security
- JWT-based Authentication: Stateless auth with access tokens (1 hour TTL) and refresh tokens (7 days TTL)
- External SSO — OIDC, LDAP & GitHub: Optional OpenID Connect, LDAP and GitHub OAuth2 login backends, toggled per-provider from the admin Integrations → Auth providers page (persisted as the
auth.oidc.enabled/auth.ldap.enabled/auth.github.enabledserver settings). OIDC uses the authorization-code flow with PKCE +state+nonceand id-token validation (GET /auth/oidc/authorize→GET /auth/oidc/callback). GitHub is plain OAuth2 + PKCE with no OIDC discovery and noid_token(GET /auth/github/authorize→GET /auth/github/callback), identifying the user fromGET https://api.github.com/user(default scopesread:user user:email) and keying the identity on GitHub's stable numeric id (github.<id>), never the renameable login or the e-mail. LDAP ridesPOST /auth/loginwith anldap:-prefixed username (e.g.ldap:jdoe). Sessions are delivered as httpOnly + Secure + SameSite=Lax cookies; the OIDC/GitHub post-login redirect is allowlisted to same-origin paths only, and the OAuthstateis additionally bound to the browser that started the flow by a short-lived HttpOnly correlation cookie (phlix_oauth_oidc/phlix_oauth_github). Account linking lets an already-signed-in user attach an external identity to their account —GET /auth/identitieslists linked identities,GET /auth/identities/link/oidcandGET /auth/identities/link/githubstart a verified OAuth round-trip link,POST /auth/identities/link/ldaplinks via a live LDAP bind, andDELETE /auth/identities/{id}unlinks one (own-identity-only, and refused with409if it would remove your last sign-in method); linking always proves control of the identity, never mints a new session, and rejects an identity already linked to another account with409. A linked identity is usable for login (it resolves your existing account instead of creating a duplicate), and the auth provider registry supports multiple instances of the same family (e.g. two OIDC issuers; configuring named instances from the admin console is a later step).- Provider configuration is DB-backed (migration
093_plugin_settings.sql, tableplugin_settings) rather than a per-pluginsettings.json; an existing file is imported once, automatically, on first read — no operator action on upgrade. The OIDC and GitHub settings saves preserve any optional key absent from the request body (only an explicitly empty value clears one), so a partial payload cannot wipe a field it does not know about. (The LDAP save still rebuilds its document from the body alone apart frombind_pw— send it the complete map.) - The provider
redirect_uriis always absolute and is pinned toPHLIX_DOMAIN. Registerhttps://<your PHLIX_DOMAIN>/auth/oidc/callback(or/auth/github/callback) at the IdP / OAuth App. If neitherPHLIX_DOMAINnor an absolute per-providerredirect_urisetting is configured,/auth/{provider}/authorizefails closed with503 callback_url_not_configured. See Environment variables and the SSO/external-auth guide in phlix-docs for the full setup, including the GitHub provider (which has admin API endpoints but no admin-SPA card yet).
- Provider configuration is DB-backed (migration
- Secure Password Hashing: Argon2ID for password storage
- Multi-Device Sessions: Track and manage sessions across devices
- User Profiles: Multiple profiles per account with parental controls
- Up to 5 profiles per user account
- Profile-specific content rating restrictions (G, PG, PG-13, R, NC-17, X, UNRATED)
- PIN protection (4 or 6 digits) for profile settings
- Genre-based filtering (allowed/blocked genre lists)
- Daily watch time limits per profile
- Content Rating Filters: Age-based access restrictions
- Audit Logging: Complete security event logging
- Security Headers / CSP: Every response carries
X-Content-Type-Options,X-Frame-Options, HSTS, and a strict Content-Security-Policy (SecurityHeaders). The SPA CSP allowsmedia-src/worker-src 'self' blob:so hls.js can attach its MSEblob:object URL and transmux Web Worker (required for browser HLS playback), and the/appshell's inline bootstrap<script>runs under a per-request script nonce rather than'unsafe-inline'; non-/appresponses keep the strict default policy.img-srcadditionally allowlists the two TMDB image CDN hosts (https://image.tmdb.organdhttps://tmdb.org, no wildcard) so poster/backdrop/cast artwork served directly from TMDB renders rather than being blocked. This is a stopgap until the generic image caching/loader work (updates.md #47 / S71-S73) proxies all remote artwork through our own origin, at which point the explicit TMDB hosts are removed.
SyncPlay - Group Watching
- Synchronized Playback: Watch content together with friends across devices with sub-second sync accuracy
- Host-Controlled Playback: Only the host can control play/pause/seek; all members receive synchronized commands
- NTP-Style Time Sync: Network time synchronization with latency compensation and drift correction
- In-Group Chat: Real-time messaging with typing indicators and message history
- Playback Queue: Host-managed queue with media info (title, thumbnail)
- Host Election: Automatic host election when current host leaves (oldest member becomes host)
- Password Protection: Optional password protection for private watch parties
- Position Tolerance: Configurable sync tolerance (default 2s) to prevent excessive seeking
Session Management
- Device Sessions: Track authenticated devices with activity timestamps
- Playback Progress: Resume where you left off across sessions
- Continue Watching: Track items in progress per profile
- Watch History: Complete viewing history per profile with:
- Automatic completion detection at 90% progress threshold
- Watch time statistics (total, daily, by period)
- Resume position tracking for seamless playback continuation
Live TV & DVR
- Multi-Tuner Support: DVB-T, DVB-S, DVB-C, and ATSC tuner types
- Channel Scanning: Automatic discovery of broadcast services
- Electronic Program Guide: Full EPG with program info, categories, and search
- DVR Scheduling: Schedule recordings with priority management
- Time-Shifting: Pause and rewind live TV with buffer
- Channel Lineups: Custom channel lineups per user
- Favorites: Personal favorite channels per user
- Storage Management: Recording storage tracking and limits
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_functionspreflight — see Swoole & php-uv on Linux.
curl -fsSL https://raw.githubusercontent.com/detain/phlix-server/master/scripts/install.sh | sudo bash
Provision HTTPS in the same run by passing your domain and a Let's Encrypt contact email:
curl -fsSL https://raw.githubusercontent.com/detain/phlix-server/master/scripts/install.sh \
| sudo bash -s -- --domain phlix.example.com --admin-email you@example.com
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:
sudo bash /var/www/phlix/scripts/install.sh --update -y
Pin to a specific tag or branch with --branch:
sudo bash /var/www/phlix/scripts/install.sh --update --branch v0.2.0 -y
--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:
sudo bash /var/www/phlix/scripts/install.sh --uninstall
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:
sudo bash /var/www/phlix/scripts/install.sh --uninstall --purge -y
What it removes when present:
- The
phlix-serversystemd unit (stop,disable, remove file,daemon-reload). - HAProxy fragment at
/etc/haproxy/phlix-managed/phlix-server.cfg.fragment, and/etc/haproxy/haproxy.cfgis 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.bakis restored (orhaproxy.cfgis removed and haproxy is stopped + disabled if no snapshot exists). - The combined PEM at
/etc/haproxy/certs/<domain>.pem. /etc/cron.d/phlix-server-certbotand the certbot deploy hook.- The Let's Encrypt cert via
certbot delete— only with--purgeor interactive confirm. - The MySQL database + user — only with
--purgeor interactive confirm. - The install dir (
/var/www/phlixby default; system paths refused). /var/phlix(config, library cache, backups) — only with--purgeor interactive confirm./var/log/phlixand/var/run/phlix./etc/phlix/env(env file).- The dedicated system user
phlixviauserdel— only with--purgeor 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.
# 1. Install phlix-hub first (with TLS). curl -fsSL https://raw.githubusercontent.com/detain/phlix-hub/master/scripts/install.sh \ | sudo bash -s -- --domain hub.example.com --admin-email you@example.com -y # 2. Install phlix-server, also with TLS, on a different hostname. curl -fsSL https://raw.githubusercontent.com/detain/phlix-server/master/scripts/install.sh \ | sudo bash -s -- --domain phlix.example.com --admin-email you@example.com -y
After both finish, /etc/haproxy/haproxy.cfg looks like:
# phlix-managed: rebuilt by phlix install scripts — do not edit ... frontend fe_https bind :443 ssl crt /etc/haproxy/certs/ http-request set-header X-Forwarded-Proto https # --- phlix-hub --- acl is_phlix_hub_host hdr(host) -i hub.example.com use_backend be_hub_client_relay if is_phlix_hub_host { path_beg /client/ } use_backend be_hub if is_phlix_hub_host # --- phlix-server --- acl is_phlix_server_host hdr(host) -i phlix.example.com use_backend be_phlix_server if is_phlix_server_host ...
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)
# Clone the repository git clone https://github.com/detain/phlix-server.git cd phlix-server # Install dependencies composer install # Run database migrations (reads config/database.php; password from DB_PASSWORD env var) DB_PASSWORD=your_strong_password php bin/phlix migrate # or: php scripts/run-migrations.php # Start the server (HTTP + WebSocket on port 8096 from config/server.php) php public/index.php start
Configuration
Configuration is managed via PHP files in config/:
// config/server.php return [ 'server' => [ 'name' => 'Phlix Media Server', 'host' => '0.0.0.0', 'port' => 8080, ], 'websocket' => [ 'host' => '0.0.0.0', 'port' => 8097, ], 'database' => [ 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'phlix', 'username' => 'phlix', 'password' => 'secure-password', ], 'debug' => false, ];
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/ WebAuthnstart+finish/ public JWKS / WS-connect on:8097) are rate-limited. HTTP surfaces reply429 Too Many Requests+Retry-Afterwith body{"error":"Too Many Requests","code":"rate_limited"}; WS-connect rejects the handshake.loginkeeps its own DB-backed IP limiter (migration 074).
Loudness normalization.
config/ffmpeg.php→loudness(disabled by default) applies an EBU R128loudnormfilter to re-encoded audio when enabled. Copy-audio rungs (theoriginalvariant) 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). Runphp bin/phlix migrate(orphp scripts/run-migrations.php) after pulling — migrations are idempotent, so re-running is safe.
Migration + one-time cleanup required on deploy (S29, updates.md #29). Adds
migrations/090_playback_state_session_media_unique.sql(documentation-only — reserves the number). Afterphp bin/phlix migrate(orphp scripts/run-migrations.php), run once:php migrations/cleanup_090.php. It merges any duplicate(session_id, media_item_id)rows inplayback_state(keeping the maxupdated_at, tie-break maxid; batched for large tables) then adds theuq_playback_state_session_mediaunique key, so playback-progress upserts update the existing row instead of inserting a new one every ~15s.scripts/install.sh/ the Docker entrypoint do NOT run it automatically (same as the migration-072cleanup_072.phpstep); until it runs, the unique key does not exist and progress writes keep duplicating. The script is idempotent, so re-running is safe.
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:
connected- Sent on successful connectionclient_disconnected- Broadcast when client disconnects
Authentication Events:
auth_request- Request authenticationauth_success- Authentication successfulauth_failure- Authentication failed
Playback Events:
playback_start- Playback startedplayback_pause- Playback pausedplayback_stop- Playback stoppedplayback_progress- Progress updateplayback_seek- Seek performed
SyncPlay Events:
syncplay_create_group- Create watch groupsyncplay_join_group- Join watch groupsyncplay_leave_group- Leave watch groupsyncplay_sync_state- State synchronization
Development
Running Tests
# Run all tests ./vendor/bin/phpunit # Run with coverage ./vendor/bin/phpunit --coverage-html coverage-report # Run specific test suite ./vendor/bin/phpunit --testsuite Unit ./vendor/bin/phpunit --testsuite Integration
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:
DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=phlix_test DB_USER=root DB_PASSWORD=root \ ./vendor/bin/phpunit --no-coverage
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.
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:
# Check code style ./vendor/bin/phpcs --standard=PSR12 src/ # Run static analysis ./vendor/bin/phpstan analyze src/ --level=9 ./vendor/bin/psalm
CLI
Administrative tasks are exposed through the bin/phlix command-line tool (built on webman/console / Symfony Console):
php bin/phlix list # list every available command php bin/phlix migrate # apply migrations/*.sql against config/database.php php bin/phlix media:dedupe-paths # preview duplicate-path merges (dry-run) php bin/phlix media:dedupe-paths --apply # merge duplicate media items sharing a path
php bin/phlix migrate is the supported equivalent of php scripts/run-migrations.php — both delegate to the same Phlix\Common\Database\MigrationRunner service, applying every migrations/*.sql file on each run (idempotent; no tracking table). More commands are added in later steps.
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).
cd admin-ui npm install # one-time / on dependency changes npm run build # tsc + vite build → ../public/assets/admin/ (commit the result) npm run test # Vitest unit/component tests npm run dev # Vite dev server (HMR) for local development
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
- Create a feature branch:
git checkout -b feature/my-feature - Make changes and commit:
git commit -am 'Add new feature' - Push to remote:
git push origin feature/my-feature - Create Pull Request on GitHub
- After review, merge via squash-merge
Contributing
- Fork the repository
- Create your feature branch
- Ensure all tests pass (
./vendor/bin/phpunit) - Follow PSR-12 coding standards
- Submit a pull request
License
Mozilla Public License 2.0 — see LICENSE for the full text.
MPL-2.0 is per-file weak copyleft. In short:
- You can use Phlix Server in a larger project, including a commercial or closed-source one.
- If you modify a file that is part of Phlix Server, you must make that file's source available under the MPL-2.0.
- New files you add alongside it can be licensed however you like.
"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.