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 06:19:30 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: Optional OpenID Connect and LDAP login backends, toggled per-provider from the admin Integrations → Auth providers page (persisted as the
auth.oidc.enabled/auth.ldap.enabledserver settings). OIDC uses the authorization-code flow with PKCE +state+nonceand id-token validation (GET /auth/oidc/authorize→GET /auth/oidc/callback; register<your-server>/auth/oidc/callbackat your IdP). LDAP ridesPOST /auth/loginwith anldap:-prefixed username (e.g.ldap:jdoe). Sessions are delivered as httpOnly + Secure + SameSite=Lax cookies; the OIDC post-login redirect is allowlisted to same-origin paths only. Account linking lets an already-signed-in user attach an external identity to their account —GET /auth/identitieslists linked identities,GET /auth/identities/link/oidcstarts an OIDC link (verified via the IdP round-trip), andPOST /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 now 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). See the SSO/external-auth guide in phlix-docs. - 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_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 |
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
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.