# Phlix Media Server [![PHPUnit](https://github.com/detain/phlix-server/actions/workflows/phpunit.yml/badge.svg)](https://github.com/detain/phlix-server/actions/workflows/phpunit.yml) [![Coding Standards](https://github.com/detain/phlix-server/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/detain/phlix-server/actions/workflows/coding-standards.yml) [![Web UI](https://github.com/detain/phlix-server/actions/workflows/web-ui.yml/badge.svg)](https://github.com/detain/phlix-server/actions/workflows/web-ui.yml) [![codecov](https://codecov.io/gh/detain/phlix-server/graph/badge.svg)](https://codecov.io/gh/detain/phlix-server) [![PHP](https://img.shields.io/badge/PHP-8.3%2B-777bb4?logo=php&logoColor=white)](https://www.php.net/) [![PHPStan](https://img.shields.io/badge/PHPStan-level%209-brightgreen)](https://phpstan.org/) [![Code style](https://img.shields.io/badge/code%20style-PSR--12-blueviolet)](https://www.php-fig.org/psr/psr-12/) Phlix is your own streaming service for the media you already own: it organizes your movies, shows, music, books and photos and plays them on every TV, phone and computer in the house — with watch parties for friends, kid-safe profiles, and cast-to-TV, all on hardware you control. Under the hood it is 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. ## 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 - **Cast to TV**: Chromecast, AirPlay, Roku and DLNA renderers — per-protocol device discovery plus transport control (play/pause/seek/stop) on Chromecast, AirPlay and DLNA; Roku's server-side surface is send-to-device, app launch and key-press routes (transport there is simulated on the TV, not commanded by the server). Chromecast/AirPlay/Roku routes sit behind `CastingEnabledMiddleware` on their `casting..enabled` switches; the DLNA renderer routes are auth-gated only — DLNA is configured separately by `dlna.enabled` (SSDP advertisement, applied at worker start) and `dlna.cds_enabled` (LAN content browsing, off by default); triage in [A cast device is not found](#a-cast-device-is-not-found) - **Transcoding**: On-the-fly media conversion via FFmpeg with automatic quality selection - **HLS Streaming**: Adaptive bitrate streaming for web clients with multi-quality playlists, normalized for browser playback — encoder-level detail under [HLS & transcoding](#hls--transcoding) - **WebSocket events**: Real-time progress and notification delivery - **Household quality-of-life**: watched status scrobbles to Trakt and Last.fm, trickplay scrub-preview thumbnails and box-set collection sync are produced by background asset jobs (`PHLIX_MEDIA_ASSET_JOBS_DIR`, `PHLIX_COLLECTION_JOBS_DIR`), and intro/outro skip markers are exposed to players — see [Metadata providers & scrobblers](#metadata-providers--scrobblers) - **Subtitles**: embedded text tracks are extracted to WebVTT on demand and served through signed URLs, HLS transcodes emit sidecar subtitle tracks, and provider plugins add searchable remote subtitles (downloaded tracks attach to the item) — see [Subtitles (choosing them)](#subtitles-choosing-them) - **Hub / remote access**: enrolling the server with a [phlix-hub](https://github.com/detain/phlix-hub) links your account to the hub's single sign-on and opens an outbound relay path, so you can reach this server from outside your home network without port forwarding. The installer's `--hub-url` (or `PHLIX_HUB_URL`) is the server-side half; pairing completes with `scripts/pair-with-hub.php`. What a hub is for, step by step: [the quickstart's hub section](docs/user/quickstart.md#remote-access-the-hub) - **Backups**: Archives combining a full MySQL dump with the server's config, data, and SSL files, with count-based retention, optional S3 replication, and automatic scheduling — admin API, `bin/phlix` and an admin-only `/app` Backup page; see [Backups (admin-managed)](#backups-admin-managed) - **Themes (appearance)**: Three built-in token-map themes (`nocturne` / `daylight` / `midnight`) plus plugin-contributed themes, served by a signed-in catalogue API; selection is per-user and validated live — no clearing path exists; see [Themes (choosing an appearance)](#themes-choosing-an-appearance) - **Multi-Source Metadata**: Automatic metadata fetching from TMDB (movies), TVDB (TV series), Fanart.tv (artwork), and local NFO files with provider fallback. The 24-hour cache freshness window is tunable (`metadata.cache_ttl_hours`), and a minimum title/year match-confidence gate for accepted provider matches (`metadata.min_match_confidence`, default `0` = gate off) — both admin-editable and read live per lookup - **Content Filtering**: Parental controls with rating and genre-based filtering - **Access Schedules**: per-profile allowed-time windows ("who may watch when") enforced at the request edge for kids' profiles and beyond — managed per profile (own-profile or admin) via `/api/v1/profiles/{profileId}/schedules`; household walkthrough in [the quickstart](docs/user/quickstart.md) - **Webhooks**: outbound HTTP event delivery through two admin-managed surfaces — a signed hook registry (per-hook secret, HMAC signature headers, SSRF-guarded targets) and a domain-event subscription surface (`user.login`, `playback.*`, `library.*`; unsigned, see the endpoint table). The same moment-of-use SSRF gate covers the notification plugin family (`src/Webhooks/Plugins/` — Slack, Discord, ntfy, Apprise, Telegram, Pushover, MQTT): every configured egress URL is re-validated against `SsrfGuard` at send time, so a config edited or shipped to point at loopback, link-local metadata, or a private range is refused with a logged warning and zero bytes out — the create+moment-of-use law the webhook surfaces proved. Redirects are never followed: every guarded egress context pins `follow_location=0`/`max_redirects=0`, so a public endpoint answering `302 →` a denied range is refused at hop one (logged, zero bytes to the target). The guard's verdict is check-time, before the socket connects; the sub-second resolver window is disclosed as a bounded residual in `AbstractNotificationPlugin`'s egress law. The family ships ahead of its dispatcher — no in-tree producer calls `send()` yet, so this is pre-deployment posture; the wiring lane owns the refusal-marking law. Troubleshooting path: [A webhook never fires](#a-webhook-never-fires) Setting a server up for your household for the first time? Follow [docs/user/quickstart.md](docs/user/quickstart.md) — install → admin account → first library → first play → household → watch party, one page, in order. The rest of this README is the reference; the quickstart is the journey. ## Architecture ```text src/ ├── Access/ # Per-profile access schedules and content access policy ├── Admin/ # Admin services: backup manager, dashboard aggregation ├── AirPlay/ # AirPlay devices: discovery, session, playback control ├── Auth/ # Authentication: login/JWT, OIDC/LDAP/GitHub providers, WebAuthn, method lockout ├── Casting/ # Casting session store + ownership shared by the AirPlay/Roku/DLNA paths ├── Chromecast/ # Chromecast devices: discovery and the Cast API client ├── Collections/ # Collections and smart-collection rule engine (manager, item repositories) ├── Common/ # Shared infrastructure: DI container, DB pool, logger, HTTP client, net guards ├── Config/ # Effective-config resolution, job-queue paths, hardware-accel config ├── Console/ # bin/phlix commands (user create/promote, migrations, install helpers) ├── Discovery/ # SSDP service discovery and NAT-PMP/PCP port mapping ├── Dlna/ # DLNA media server: CDS, AVTransport, device descriptions ├── Hub/ # Hub relay client: enrollment, Ed25519 keys, :8804 tunnel, relay config ├── LiveTv/ # Channels, EPG, Comskip EDL parsing, recordings (transcode-gated streams) ├── Media/ # Media library domain (subsystems below; root files add markers/recommendations/similarity) │ ├── Extras/ # Extras (behind-the-scenes, trailers) domain │ ├── Library/ # Scanners, per-type LibraryManagers, ItemRepository (browse query layer) │ ├── Markers/ # Playback markers domain │ ├── MediaAsset/ # Media asset persistence │ ├── Metadata/ # Providers (TMDB, TVDB, Fanart, AniDB, MyAnimeList, NFO/local) + match/cache policies │ ├── Music/ # Track/album/artist domain services │ ├── Playback/ # Resume positions, user item data │ ├── Storage/ # Library storage paths and capacity │ ├── Streaming/ # HLS packaging, segment prep, adaptive bitrate │ ├── Subtitles/ # Subtitle extraction and conversion │ └── Transcoding/ # FFmpeg jobs: FfmpegRunner, TranscodeManager, EncodeSettings ├── Network/ # Swoole coroutine socket guards and fault containment ├── Playlists/ # Smart-playlist rule DTOs (RuleNode/RuleOperators) consumed by Collections ├── Plugins/ # Plugin runtime: catalog, contracts, OAuth2 callbacks, enable bootstrap ├── Roku/ # Roku control point: discovery + RemoteRokuClient ├── Server/ # Process bootstrap and request surface (subsystems below) │ ├── Arr/ # *arr integration glue (custom formats, Workerman transport) │ ├── Core/ # Application bootstrap — owns the HTTP route registrations │ ├── Http/ # REST layer: Request/Response/Router, Controllers/, Middleware/, FastPath/ │ ├── Integrations/ # Service integrations: Last.fm, Trakt │ ├── Runtime/ # Boot identity, PID files, listen endpoints, hook delivery │ ├── Updates/ # Core update-check service and version-marker fetchers │ ├── WebSocket/ # Real-time :8097 server: connections, pool, MessageHandler, SyncPlay auth │ ├── WebPortal/ # /app SPA boundary: WebPortalRouter JSON, SharedUiController shell, ViteAssets │ └── Workerman/ # Workerman HTTP worker (HttpHandler.php — the served daemon) ├── Session/ # Playback sessions + SyncPlay group management │ └── SyncPlay/ # Watch-party group state, bridge, snapshots, time sync ├── Stats/ # Client heartbeats and metrics stores ├── Theming/ # Built-in theme registry + theme source seam (plugin themes) └── Webhooks/ # Outbound webhook dispatch and delivery records public/ # no front controller — start.php serves everything (see below) ├── templates/emails/ # Smarty newsletter email template (only remaining .tpl) └── assets/ # Static assets (css, js, images) + assets/app/ (built Vue SPA bundle) ``` ## Requirements - **PHP**: 8.3 or higher - **MySQL**: 8.0.13 or higher (latest 8.0 patch release recommended), or MariaDB 10.6+ (The 8.0.13 floor is real, not conservatism: migrations such as 012 use expression defaults (`DEFAULT ('[]')`), which MySQL only accepts from 8.0.13 — MariaDB has allowed expression defaults since 10.2.) (On MariaDB — and on MySQL below 8.0.13/8.0.17 — the MySQL-only functional / multi-valued index statements in migrations 011 and 050 are skipped with an operator-visible note via the `@phlix-require-mysql` dialect gate in `src/Common/Database/MigrationRunner.php`. They are performance-only, and 051 supersedes the multi-valued one with a join table MariaDB builds natively, so no query or feature is lost — only those specific index scans stay slower.) - **PHP extensions**: the mandatory set lives in one place — [`scripts/required-php-extensions.php`](scripts/required-php-extensions.php) — the derived contract that `composer.json`'s 24 `ext-*` requires and its `config.platform` pins are held identical to by `RequiredPhpExtensionsContractTest` (an entry whose symbol stops existing in the cited file goes RED, so the list cannot rot silently). - **Swoole**: strongly recommended (the installer compiles it; the Alpine Docker image bundles it, while the hardware-accel variants — `Dockerfile.nvidia`, `Dockerfile.intel` — install PHP from apt without it, so pooled coroutines do not apply there and in-worker queries run sequentially). The server boots without it — Workerman falls back to its plain loop and the curated coroutine hooks in `start.php` are skipped — but the per-worker database coroutine pool (`DB_POOL_ENABLED`) has no coroutines to lease connections to, so in-worker queries run sequentially instead of in parallel (`config/database.php`). **Host installer floor: Linux kernel ≥ 6.9.** `scripts/install.sh` compiles Swoole with the io_uring flags ON (the container-only seccomp reason for shipping them OFF in Docker does not exist on a host), and Swoole's io_uring loop is FATAL at init below 6.9 — there is no silent epoll fallback once the flags are compiled in. On an older kernel use the Docker image (`SWOOLE_IOURING=0` default) or compile Swoole without `--enable-iouring` / `--enable-uring-socket`; the installer records the same floor in its own comment. - **Workerman**: 5.1+ (bundled via Composer — `composer.json` pins the `~5.1` constraint) - **FFmpeg**: for transcoding (optional) Every environment variable the server process reads is inventoried in the definitive census — [Environment variables](#environment-variables) — with defaults, secrets, and restart semantics. ## Features ### Foundation - **PSR-11 DI container (PHP-DI 7)**: auto-wired services with provider-based composition; see [phlix-docs / dev / architecture-server](https://detain.github.io/phlix-docs/dev/architecture-server). - **PSR-14 event dispatcher (Tukio)**: playback, library-scan, and auth lifecycle events with typed `readonly` DTOs. Plugins subscribe by event class FQCN; see [phlix-docs / dev / event-reference](https://detain.github.io/phlix-docs/dev/event-reference). - **Plugin system**: install / enable / disable / uninstall lifecycle, sandboxed per-plugin `vendor/` directories, signature-verified manifests (a tampered signature is always refused; enforcing ABSENT signatures is opt-in — see Plugin trust model under Environment variables), and PSR-14 event subscription via `Phlix\Shared\Plugin\LifecycleInterface` (the `Phlix\Plugins\Contract\LifecycleInterface` FQCN remains a deprecated bridge through 0.11.x). Installing is not enabling: install only stages the plugin (files + sandboxed autoload), **enable** is what activates its registration (routes, event subscriptions, settings), and disabling is the first troubleshooting step when a plugin misbehaves. **Plugin developer guide:** [phlix-docs / plugins / developer-guide](https://detain.github.io/phlix-docs/plugins/developer-guide). Server-internals reference for contributors extending the loader: [phlix-docs / dev / plugin-sdk](https://detain.github.io/phlix-docs/dev/plugin-sdk). Reference plugin: [`detain/phlix-plugin-example`](https://github.com/detain/phlix-plugin-example). The only in-tree example is `examples/plugins/phlix-plugin-sample-theme` — a complete, installable **`ui-theme`** plugin (theme surfaces only); functional (metadata/event) plugin examples ship in that external repo, not here. - **Shared interfaces / DTOs in `detain/phlix-shared`**: framework-neutral Composer package shared with `phlix-hub`. `Phlix\Shared\Plugin\*`, `Phlix\Shared\Events\*`, `Phlix\Shared\Auth\JwtClaims`, `Phlix\Shared\Metadata\MetadataSourceInterface` (the typed metadata-source plugin contract, since `phlix-shared` 0.15.0), and `Phlix\Shared\Hub\*` DTOs live there since `phlix-server` 0.11.0. - **Library matching & de-duplication**: filename→title cleaning strips multi-word noise suffixes (`Directors Cut`, `YIFY`, …) before metadata matching (admin-tunable via `matching.noise_suffixes`); a canonical-key resolver prevents duplicate top-level series/movies at scan time; and the admin **Duplicates** page + `scripts/dedup-series.php` merge historical duplicates. Metadata source order is configurable per media type via the `metadata.provider_priority` setting. See [phlix-docs / admin / library-management](https://detain.github.io/phlix-docs/admin/library-management) and [phlix-docs / admin / server-settings](https://detain.github.io/phlix-docs/admin/server-settings). ### Web portal - **Web portal (`/app`) — the only web UI**: The web portal is the shared `@phlix/ui` Vue SPA, served at `/app` (SPA shell via `SharedUiController` + `ViteAssets`; built bundle under `public/assets/app/`). Admins work in the **admin section (`/app/admin`)**, an admin-gated route family of the same SPA — "web portal (`/app`)" and "admin section (`/app/admin`)" are this README's canonical pair for the browser surfaces. Routes + nav are registered in `web-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`), and the admin section (`/app/admin/*`). - **Legacy Smarty page rendering removed**: The old server-rendered (Smarty) portal pages — `PageRenderer`, the page controllers, and all `public/templates/**/*.tpl` page templates — have been **deleted**. Their old paths now **302-redirect** to the `/app` equivalent (e.g. `/login` → `/app/login`, `/library` → `/app`, `/player/{id}` → `/app/player/{id}`, `/music`, `/books`, `/audiobooks`, `/photo` → their `/app` pages), dispatched in `src/Server/Workerman/HttpHandler.php` (the served daemon; an older `public/index.php` copy of that logic was unserved and deleted). 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**: library browsing, media info, user data, collections, casting, SyncPlay and admin surfaces. The [HTTP endpoints](#http-endpoints) table below is the curated operator view; **`openapi.yaml` is the exhaustive list** — a machine-checked bijection with the composed route registry, CI-enforced by `OpenapiSpecCurrencyTest` - **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 - **Collections & Playlists**: Member-owned media lists with a `created_by` visibility model — see [Collections and playlists](#collections-and-playlists-member-owned-lists) ### Authentication & security - **JWT-based Authentication**: Stateless auth with access tokens (1 hour TTL) and refresh tokens (7 days TTL) - **Signup Approval Queue**: new self-service registrations wait for an admin by default — see [Signup approval gate (adding household users)](#signup-approval-gate-adding-household-users) - **External SSO — OIDC, LDAP & GitHub**: Optional external login backends, toggled per-provider from the admin **Integrations → Auth providers** page. Flows, endpoints and config pointers: [External SSO](#external-sso). - **Auth-method lock-out guards**: a settings write or provider disable that would leave every sign-in method off — or an active administrator with no usable factor — is refused with `422` and a machine-readable reason, and nothing is persisted. Rules, reason tokens and recovery: [Auth-method lock-out guards](#auth-method-lock-out-guards). - **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 — **stored, not enforced**: the `max_daily_watch_time` setting persists through the profile settings API, but no playback path reads it (and there is no live per-profile watch-time ledger to measure against), so a configured cap never restricts anything - Access schedules (allowed-time windows) — configured per profile via `/api/v1/profiles/{profileId}/schedules`; steps in [the quickstart §4 (Add the household)](docs/user/quickstart.md#4-add-the-household) - **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 allows `media-src`/`worker-src 'self' blob:` so hls.js can attach its MSE `blob:` object URL and transmux Web Worker (required for browser HLS playback), and the `/app` shell's inline bootstrap `