CLI Reference β
The Phlix Media Server and the Phlix Hub each ship a bin/phlix console entrypoint built on webman/console (a thin wrapper around Symfony Console). A handful of operations that are network-, daemon-, or TLS-bound are still provided as standalone scripts/*.php and are documented at the end of this page.
bin/phlix β what it is β
webman/console only auto-discovers commands from an app/command directory, which neither repo has (both use a PSR-4 Phlix\β¦ β src/ layout). So each repo ships a small bin/phlix executable that bootstraps the autoloader and config, then explicitly registers its Phlix\β¦\Console\Commands\* classes on a Webman\Console\Command application and runs it.
bin/phlix is a one-shot CLI β not the resident Workerman/Swoole worker β so it does not start the event loop. The DI container and the database connection are resolved lazily: a command only builds the container or opens a connection when it actually needs one. Consequently:
php bin/phlix list # list every available command (no database needed)
php bin/phlix help <command>php bin/phlix list works with no database configured or reachable β it never builds the container, so you can always discover the available commands.
All commands return a standard exit code: 0 on success, 1 on failure (the error is written to output). Commands never call exit()/die() internally.
phlix-server commands β
Run from the phlix-server install directory (phlix-server/). Twelve commands are available.
| Command | Arguments / options | Description |
|---|---|---|
migrate | β | Apply database migrations (migrations/*.sql). |
library:list | β | List all configured media libraries. |
library:scan | {libraryId} [--rescan] [--force] | Scan (or, with --rescan, fully re-read) a media library. |
plugin:list | β | List installed plugins and their enabled state. |
plugin:enable | {name} | Enable an installed plugin by name. |
plugin:disable | {name} | Disable an enabled plugin by name. |
plugin:install | {source} | Install a plugin from a source URL. |
plugin:uninstall | {name} | Uninstall a plugin by name. |
backup:create | [--label=] | Create a new server backup archive. |
backup:list | β | List stored server backups. |
hwaccel:probe | β | Probe for available hardware-acceleration encoders. |
user:reset-password | {user} [--password=] | Reset a user's password by username or email. |
Server migrate β
Applies every migrations/*.sql file in sorted order. Idempotent: it has no migration-tracking table and is safe to run repeatedly β duplicate-column / duplicate-key / "already exists" errors are downgraded to notes rather than treated as failures. This is the supported equivalent of php scripts/run-migrations.php (both delegate to the same Phlix\Common\Database\MigrationRunner); the script remains for the Docker entrypoint and installer.
php bin/phlix migrateReturns exit 1 if a genuine (non-idempotent) statement error occurs.
library:list β
Prints the id, name, type, and configured path(s) of each library as a table.
php bin/phlix library:listlibrary:scan β
Scans a library for new content.
This command runs synchronously and blocks until the scan completes. (The HTTP POST /api/v1/libraries/{id}/scan endpoint is asynchronous instead β it queues a job; see the Library Scan Worker.)
| Argument / option | Description |
|---|---|
libraryId (required) | The library identifier to scan. |
--rescan | Full rescan: re-walk the tree, index files at paths not yet in the catalogue, backfill missing source metadata on video / movie / episode / audio / audiobook rows, then prune items whose file is gone. User data is preserved, including for a top-level item whose file was moved without being renamed β the prune re-points the row instead of deleting it (S158). A move that also renames still creates a new row; see Moving a file. On a music library it additionally re-reads every track's tags (this can take hours) and is what repairs tracks filed under the wrong album/artist; on every other library type the re-read flag is ignored. |
--force | Start even when the library already has a queued/running scan job. |
php bin/phlix library:scan 3
php bin/phlix library:scan 3 --rescan--rescan does not purge the library β
--rescan never purged, and the old description of this flag ("clear existing items and rescan from scratch") was wrong in both halves. What it does is re-walk the library tree, index files at paths that are not yet in the catalogue, backfill missing source metadata on the already-existing rows whose type is video, movie, episode, audio or audiobook (photo and book rows get nothing), and then prune the items whose source file has disappeared. Use a plain scan for an incremental refresh.
A moved file keeps its row (S158)
A parent-less item β movie, video, photo, book, audiobook β whose file was moved without being renamed is matched by a path-independent canonical key, and the prune in the same --rescan re-points the existing row at the new path rather than deleting it. The UUID, user_item_data and resume position survive, and no flag is needed. Episodes were never affected.
A move that also renames the file still produces a new row with a new UUID β see Moving a file.
--rescan re-reads every file on MUSIC libraries only β
The flag reaches the scanner as readEveryFile, and only the music scanner consumes it β the other scanners have no skip index for it to switch off:
- On a music library it makes the scanner refuse every unchanged-file skip, so every track is opened and tag-read. That is what repairs a track filed under the wrong album or artist after a retag: the incremental skip normally fires before the file is opened, so a plain scan does not see the corrected tags. (The skip index is still loaded during a rescan β it is consulted only to avoid rewriting identity stamps that are already correct, never to suppress a read. See Rescan loads the index on purpose.)
- On a movie, TV, photo, book or audiobook library a path that is already in the catalogue is not re-parsed and not re-matched. The scan does a missing-source-metadata backfill on that row and moves on β and even that is type-gated to
video/movie/episode/audio/audiobook, so on a photo or book library the existing-item branch does nothing at all.--rescanwill not fix a wrong or duplicated match on those types.
There is no CLI command for metadata matching at all β bin/phlix ships library:scan and library:list and nothing else for libraries, so the remedies below are HTTP-only:
- Movie / TV β
match-metadatafor an item that was never matched, the single-item match for one that matched the wrong title. A wrong match is not fixed byrefresh-metadataon its own; see Fixing a wrong match. - Photo / book / audiobook β nothing. The metadata jobs skip every row of those types and complete having processed zero items; see what Match metadata skips.
A CLI scan is visible in the admin UI β
The command creates its own library_scan_jobs row up front (typed scan or rescan), streams items_found / items_updated / current_path onto it through the same throttled sink the background worker uses, and stamps completed / failed on exit β including when it is killed by SIGTERM, SIGINT or SIGHUP, so a cancelled run never strands a permanently-running row. The admin Libraries page therefore shows a live badge and progress bar for a CLI scan just as it does for a web-triggered one.
Job tracking is observability only and never blocks the scan: if the job store is absent or unreachable the command warns on stderr and scans anyway.
It refuses to start when a scan is already in flight
If the library has any job in queued or running β not merely its newest one β the command exits with a failure code and does not scan. The check is an existence test over the whole set (WHERE library_id = ? AND status IN ('queued', 'running') LIMIT 1), which is deliberate: "newest" reports idle for a library that is actively being scanned whenever a newer terminal job sits alongside an older live one (say a metadata job that completed while a CLI rescan from ten minutes earlier is still running). Two scanners over one library race on every per-file lookup, and they share one job row's worth of UI, so the badge would report one scan's progress under the other's counters.
β This can therefore disagree with the admin Libraries badge, which does show the newest row. That is intended β the badge answers "what happened last", the refusal answers "is it safe to start another scan". A library showing a completed badge can still be refused because of an older stranded running row.
--force overrides the refusal. Use it only to get past a row left running by a kill -9 or a power loss, which no in-process handler can clean up. (Restarting phlix-server also clears such rows β the scan worker reaps every running row at boot.)
Two residual behaviours, stated rather than glossed: a phlix-server restart during a long CLI scan marks that row failed while the CLI keeps running (the worker's boot reaper is unscoped and has no age guard), and SIGKILL cannot be trapped by anyone, so it leaves the row running until the next server boot reaps it. Both are false badges, never a lost scan.
Exit codes β
| Code | Meaning |
|---|---|
0 | The scan completed and indexed every file it read. |
1 | The scan did not run (unknown library, the scan threw, or it refused because a job was already in flight). |
3 | The scan completed but could not index every file it read β the library is now missing N files. |
Exit 3 is deliberately not 2: Symfony reserves 2 for invalid input/usage, which is the meaning code 1 already covers here. A wrapper switching on the exit code can therefore tell "fix your arguments" from "files were silently lost".
On exit 3 the job row stays completed, not failed β the scan completed; the lost files are reported through the row's items_failed counter, matching what the background worker does for the same case.
plugin:list β
Lists installed plugins with their version and enabled state (yes/no) as a table.
php bin/phlix plugin:listplugin:enable / plugin:disable / plugin:uninstall β
Each takes a required name argument β the plugin's manifest name.
php bin/phlix plugin:enable my-plugin
php bin/phlix plugin:disable my-plugin
php bin/phlix plugin:uninstall my-pluginplugin:install β
Installs a plugin from a source. The source argument is required and accepts an HTTPS URL or a file:// path for local sources. (Plain http:// is rejected unless PHLIX_PLUGINS_ALLOW_HTTP is enabled β see Environment variables.)
| Argument | Description |
|---|---|
source (required) | The plugin source URL (HTTPS, or file:// for local sources). |
php bin/phlix plugin:install https://plugins.example.com/my-plugin.zipOn success it prints the installed plugin's name and version.
backup:create β
Creates a new server backup archive and prints the backup id, file path, and size.
| Option | Description |
|---|---|
--label= | Optional human-readable label for the backup. |
php bin/phlix backup:create
php bin/phlix backup:create --label="before 1.2 upgrade"backup:list β
Lists stored backups (id, label, size, location β local or S3 β and creation time) as a table.
php bin/phlix backup:listhwaccel:probe β
Probes for available hardware-acceleration encoders/decoders using the configured ffmpeg binary (from config/ffmpeg.php) and renders the detected vendor, encoder, decoder, HDR support, and codecs per capability. Needs neither the container nor a database.
php bin/phlix hwaccel:probeuser:reset-password β
Resets a user's password, looking the user up by username first, then by email. The password is hashed with Argon2ID before storage.
| Argument / option | Description |
|---|---|
user (required) | The username or email of the user to reset. |
--password= | The new password. When omitted, a strong random password is generated and printed to stdout. |
# Generate and print a strong random password
php bin/phlix user:reset-password alice@example.com
# Set a specific password (not echoed back)
php bin/phlix user:reset-password alice --password='S3cret!Passphrase'Returns exit 1 if the user is not found.
phlix-hub commands β
Run from the phlix-hub install directory (phlix-hub/). Two commands are available.
| Command | Arguments / options | Description |
|---|---|---|
migrate | β | Apply database migrations (migrations/*.sql). |
smoke:jwt | β | Smoke-test the JWT create-validate round-trip. |
Hub migrate β
Applies the hub's pending migrations/*.sql. Unlike the server's migrate, the hub uses a real migration-tracking table (Phlix\Hub\Common\Database\MigrationRunner), so already-applied migrations are skipped. It is the supported equivalent of php scripts/run-migrations.php.
php bin/phlix migrateOutput reports each newly applied file and the total, or "All migrations already applied. Nothing to do." when up to date.
smoke:jwt β
Runs a self-contained JWT create-then-validate round-trip using a throwaway test secret (no config or database). Useful for verifying the JwtHandler β JwtClaims wiring after a deploy. Prints OK: JWT round-trip succeeded and the asserted claim fields on success; returns exit 1 on any mismatch.
php bin/phlix smoke:jwtStandalone scripts (not yet bin/phlix commands) β
The following operations remain scripts/*.php rather than bin/phlix commands. They are network-, daemon-, or TLS-bound and are deferred to a later phase; there is no bin/phlix equivalent for them today.
php scripts/pair-with-hub.php <hub-url> <server-name> β
Initiates pairing between this server and a Phlix Hub instance.
Arguments:
| Argument | Description |
|---|---|
hub-url | Base URL of the hub (e.g. https://hub.example.com). |
server-name | Human-readable name shown on the hub dashboard. |
Example:
php scripts/pair-with-hub.php https://hub.example.com "Alice's NAS"Output:
Pairing initiated.
Claim code: ABCD-1234
Enter this code at https://hub.example.com/claim-server
Waiting for claim... (press Ctrl+C to cancel)
Claimed! Server ID: 550e8400-e29b-41d4-a716-446655440000
Enrollment stored.
Pairing complete. Server is now connected to the hub.
Heartbeat loop has been started in the background.Behavior:
- Generates (or loads existing) Ed25519 keypair from
config/hub-server-key.pem. - Sends a claim request to
POST <hub-url>/api/v1/server-claims/new. - Displays the returned claim code for the operator to enter on the hub's web portal.
- Polls
GET <hub-url>/api/v1/server-claims/{claimId}every 2 seconds. - On successful claim, stores enrollment JWT to
config/hub-enrollment.json. - Starts the background heartbeat loop.
Exit codes:
0β Pairing completed successfully.1β Error (network failure, invalid arguments, hub rejection).
See Phlix\Hub\HubClient and docs/dev/pairing-protocol.md.
php scripts/claim-subdomain.php β
Claims a *.phlix.media subdomain for the enrolled server after pairing. Note: automated TLS provisioning is not implemented β certificates must be provisioned out-of-band (see TLS Certificates).
php scripts/port-forward.php <command> β
Manages UPnP-IGD and NAT-PMP port forwarding for direct server access without a relay tunnel.
Commands:
| Command | Description |
|---|---|
status | Show current port forwarding status, enabled state, method, and hostname candidates. |
enable | Attempt automatic port forwarding via UPnP-IGD or NAT-PMP. Falls back to manual instructions on failure. |
disable | Remove all port mappings and disable automatic port forwarding. |
info | Display detailed network information: local IP, public IP (via STUN), port accessibility, and UPnP IGD discovery status. |
help | Show usage information. |
Example output (status):
Port Forwarding Status
=======================
Enabled: YES
Method: upnp
External IP: 203.0.113.42
Port: 32400
Endpoint: 203.0.113.42:32400
Hostname Candidates:
[lan] http://192.168.1.100:32400
[lan-mdns] http://phlix.local:32400
[public] http://203.0.113.42:32400Example output (info):
Network Information
====================
Local IP: 192.168.1.100
Port: 32400
Testing STUN (public IP detection)...
Public IP: 203.0.113.42
Port 32400 on 203.0.113.42: OPEN
UPnP IGD Discovery...
Gateway: http://192.168.1.1:1900/gateway.xml
External WAN IP: 203.0.113.42How it works:
- UPnP-IGD β Sends SSDP M-SEARCH to
239.255.255.250:1900to discover a UPnP InternetGatewayDevice, then uses SOAPAddPortMappingto open the port. SeePhlix\Network\UpnpIgdClient. - NAT-PMP β Falls back to Apple NAT-PMP (RFC 6886) on routers like AirPort Extreme. See
Phlix\Network\NatPmpClient. - STUN β Uses RFC 5389 STUN binding to discover the server's public IP address and test port accessibility. See
Phlix\Network\StunClient.
See also: docs/hub/remote-access.md.
php scripts/run-marker-detection-worker.php β
Intro/outro marker-detection background worker (an infinite daemon loop). This script is a carry-over and is not wrapped as a bin/phlix command β it belongs to a future queue/worker step.
php scripts/dedup-series.php [--library=ID] [--dry-run|--apply] β
Find and (optionally) merge duplicate top-level items (series and movies) across the catalog. Title-slug variance β separators, year bleed, a flatβper-directory re-scan, or a concurrent-scan race β can create a second top-level row for the same show or film (the classic "100 episodes + 1 episode" symptom). This script is the offline counterpart of the admin Duplicates page / POST /api/v1/admin/media/merge endpoint: it runs DuplicateFinder per library and, on --apply, collapses each group with SeriesMerger.
Options:
| Option | Description |
|---|---|
--library=ID | Restrict to a single library UUID. Omit to process every library. |
--dry-run | List the duplicate groups that would be merged, without mutating anything. This is the default. |
--apply | Actually merge each group (re-parent children onto the primary, delete empty shells / duplicate movie rows). |
Behavior:
- Default mode is dry-run β you must pass
--applyto make changes. - The "primary" of each group is the member with the most descendants; the rest are re-parented into it. Re-parented episodes keep their ids, so per-user playback progress survives; only empty shells and duplicate movie rows are deleted (their own per-user rows go via
ON DELETE CASCADE). - A re-run after
--applyreports zero groups (idempotent).
Prerequisite: migration 043_media_items_canonical_key.sql (adds a nullable, non-unique canonical_key column + a (library_id, type, canonical_key) index). There is intentionally no UNIQUE constraint β historical duplicates exist; uniqueness is enforced in application code at scan time.
Example:
# Preview duplicate groups in one library (no changes)
php scripts/dedup-series.php --library=550e8400-e29b-41d4-a716-446655440001 --dry-run
# Merge duplicates across every library
php scripts/dedup-series.php --apply