CLI Commands
Baander ships console commands for users, libraries, catalog records, metadata, background jobs and the server. Application console commands run inside the application container; worker deployment uses a separate host entrypoint.
For an installed instance, use the host shell and dc function established in
Getting started:
dc exec app php bin/console app:user:create --help
A plain php bin/console … example assumes a shell inside the application
container, in its application directory. Some reference pages retain
make exec cmd="php bin/console …" development examples. Those select the
checkout's preview, not the installed instance. For installed-instance work, run
the same console arguments with dc exec app php bin/console …. See
Which installation a command targets
before changing targets. Paths used by a console command are container paths.
Worker deployment
Read Background work for lifecycle and observation procedures. This section defines the host manifest and credentials contract.
Run bin/worker-deployment.php from a trusted host checkout with Composer dependencies,
PHP 8.5 with extensions posix and pdo_pgsql, and an absolute Docker executable connected
to a local Unix socket. The application container receives no Docker authority.
Provision the migrated database, Redis and a private Docker network first; use a
locally available immutable image ID. The version 5 recipe admits a fixed pair
of control and execution containers,
with separate resource ceilings and disjoint CPU sets. It requires explicitly
authorized shared media volumes and a private named IPC volume. Devices are
refused; the production source policy also refuses bind mounts.
Build the worker image with the same layout-aware builder used in Getting started. Run on the host after installing its standalone tooling dependencies:
php scripts/build-application.php --target worker --tag baander-worker:local --compose-env .env.worker-image.local
. "$(pwd)/.env.worker-image.local"
export APP_HOME
The generated file contains BAANDER_APPLICATION_IMAGE and APP_HOME, without
application credentials. Put that immutable image ID in the manifest's imageId;
keep the corresponding APP_HOME exported for each controller invocation. The
controller rejects an unset APP_HOME. The storage targets below illustrate the
current /var/www/html image layout; align them with the selected image's layout.
The manifest is a JSON object of at most 8192 bytes. Version 5 requires exactly these fields, with JSON integers for version and resource values:
{
"version": 5,
"sourceMountPolicy": "production",
"namespace": "worker.baander.app",
"bootId": "0123456789abcdef0123456789abcdef",
"daemonId": "replace-with-docker-info-ID",
"imageId": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "baander-workers",
"dockerBinary": "/usr/bin/docker",
"dockerEndpoint": "unix:///var/run/docker.sock",
"controlMemoryMiB": 2944,
"executionMemoryMiB": 4800,
"controlNanoCpus": 1000000000,
"executionNanoCpus": 2000000000,
"controlPidsLimit": 129,
"executionPidsLimit": 257,
"controlCpuSet": [0],
"executionCpuSet": [1, 2],
"scheduledConsoleMiB": 320,
"autoscaling": {
"minimumConcurrency": 1,
"maximumConcurrency": 2,
"sampleIntervalMs": 1000,
"maxSampleAgeMs": 3000,
"targetBacklogAgeMs": 10000,
"scaleUpSustainMs": 3000,
"scaleDownSustainMs": 30000,
"changeCooldownMs": 5000,
"cpuCeilingPercent": 85,
"memoryCeilingPercent": 85,
"pidsCeilingPercent": 85,
"memoryStopPercent": 95,
"pidsStopPercent": 95,
"coldDurationMs": 1000
},
"ipcVolume": "baander-worker-ipc",
"mediaMounts": [
{"volume": "baander-source", "target": "/media", "readOnly": true, "role": "source"},
{"volume": "baander-conversions", "target": "/storage/conversions", "readOnly": false, "role": "derived"},
{"volume": "baander-storage", "target": "/var/www/html/storage", "readOnly": false, "role": "derived"},
{"volume": "baander-transcode-state", "target": "/var/www/html/var/transcode_state", "readOnly": false, "role": "derived"}
]
}
Replace the daemon/image examples with docker info --format '{{.ID}}' and the
local image's docker image inspect --format '{{.Id}}' values on the same endpoint.
Generate a fresh 32-character lowercase hexadecimal boot ID for each deployment
attempt. Control requires at least 2944 MiB and 129 PID slots, including a 320 MiB
reservation for its independent autoscaler. With maximum ordinary concurrency
max, execution requires at least 4160 + 320 * (max - 1) MiB plus its scheduled
console reservation, and 256 + max - 1 PID slots.
Scheduled console execution is disabled at 0 or reserves at least 320 MiB in
execution. The example enables it with a 4800 MiB execution ceiling. CPU sets must
be nonempty and disjoint; select available host CPUs explicitly. The example's
CPU placement and quotas must fit the host; the measured envelope below applies
to the recorded hardware and fixture. Docker counts threads toward each unit's
PID ceiling.
Creation also requires host memory for both ceilings plus 1 GiB and verifies that
the declared CPU identifiers are available on that daemon.
The autoscaling object is required. Every field must be a JSON integer; missing
or unknown fields are rejected. All fields contribute to both unit recipe
fingerprints, including timing and pressure thresholds. There is no compatibility
parser for older manifests. Changing any profile field requires retirement and a
fresh boot; see Upgrading.
The example recommends minimum one and maximum two for ordinary work on the measured envelope: an AMD Ryzen 7 5800X3D host with eight cores, sixteen threads and 64 GiB of physical RAM, with one CPU reserved for control, two disjoint CPUs for execution, and dependencies/web on another CPU. Two 48-message bursts of two-second simulated I/O completed in 56.99 and 56.97 seconds, compared with 97.42 and 97.43 seconds at maximum one, about 1.71× faster. The maximum-two run returned to target one between bursts and reduced the target under CPU pressure. Maximum four completed the bursts in 39.21 and 38.48 seconds, 2.48× and 2.53× faster than maximum one. It is an option for this I/O-heavy envelope with an execution ceiling of 5440 MiB and 259 PID slots, including the same 320 MiB scheduled console reservation. Maximum two is the general starting recommendation: it uses fewer resident processes and connections, and maximum four did not improve the measured CPU-work completion time.
This recommendation covers the recorded synthetic workload and placement; it does not establish external-provider latency, unrestricted concurrency or smaller-host capacity. The first burst follows harness work and already initialized duration history. During media pressure, three video encoders were active and one audio encoder was deliberately stopped after initial output to retain its occupied slot; uninterrupted audio throughput during pressure was not measured. See the qualification record for results and limits. The internal profile constructor remains conservative at maximum one; every production manifest must supply all fourteen fields explicitly.
Concurrency bounds must satisfy 1 <= minimumConcurrency <= maximumConcurrency <= 4. Durations are
100 through 3600000 milliseconds. Sample age must cover at least two intervals;
scale-up sustain and cooldown must cover an interval; scale-down sustain must
exceed scale-up sustain. Percentages are 1 through 100, with memory/PID stop
thresholds strictly above their growth ceilings.
Execution starts all consumer-0 through consumer-(max - 1) ordinary consumers
at boot. A lower admission target parks free consumers before transport receive;
it does not stop them or reclaim their RSS, connections or reservations. Occupied
permits remain held through handling and completion. After a shrink, new receives
wait until occupied permits fall below the new target. The scheduler consumer and
media slots retain separate reservations.
The fixed media workload admits three video jobs, one audio rendition and no devices. The worker argv, PHP management limit, identity and lock directory are fixed by the manifest parser; arbitrary commands, environment fields and secret paths are rejected.
Provision the named media and IPC volumes before creation, with the local Docker
volume driver and no driver options. The IPC volume is private to the pair and
mounted at /run/baander-worker; do not share it with another boot or web. Before
create, set the IPC volume root owner and group to the runtime UID/GID of the
selected worker image, with mode 0700. Docker creates a new volume root owned
by root, and the admitted mount uses volume-nocopy; an unprepared root prevents
the worker from creating its private boot directory. Read the image's runtime
identity rather than assuming a numeric UID/GID. Runtime creates boot directories
with mode 0700 and the native socket with mode 0600.
sourceMountPolicy is required. Use production for the named-volume recipe
shown here. local-development also admits canonical, read-only host bind sources
for the development dispatcher. Bind sources must be visible to the Docker daemon
and readable under the host's SELinux policy; admission does not relabel paths.
Derived storage remains named-volume storage under either policy.
Source volumes are read-only in worker; derived volumes are writable. The
declarations must cover /var/www/html/storage,
/var/www/html/var/transcode_state and the configured CONVERT_STORAGE_PATH.
At least one source volume is required. Set MEDIA_STORAGE_PATH explicitly to the
source root inside that volume, for example /media/library; never point it at
derived output. Its canonical absolute path must be covered by a read-only source
declaration. Use the same MEDIA_STORAGE_PATH in web. Mount the same volumes at the same paths
in web, giving web writable access where authorized library deletion requires it.
Copy existing media into these volumes explicitly; the development Compose bind
mounts do not provide this shared topology. Do not reset or replace existing media.
Mount targets cannot overlap or replace code, secrets, operating-system paths or worker-private paths. Production bind mounts, devices, extra mounts and source volumes aliased as writable derived storage are refused. Creation/start/recovery checks verify declared names, concrete local-volume mountpoints and access modes. A changed recipe requires a fresh admitted deployment.
Keep credentials in a separate canonical absolute regular file owned by the
invoking user, with mode 0600, no symlink components and at most 8192 bytes:
{
"controllerDatabaseUrl": "postgresql://worker:replace-password@db-control.baander.app/baander?serverVersion=18&charset=utf8",
"runtimeEnvironment": {
"APP_ENV": "prod",
"APP_DEBUG": "0",
"APP_SECRET": "replace-with-runtime-secret",
"DATABASE_URL": "postgresql://worker:replace-password@db-worker.baander.app/baander?serverVersion=18&charset=utf8",
"REDIS_URL": "redis://:replace-password@redis.baander.app:6379",
"MESSENGER_TRANSPORT_DSN": "redis://:replace-password@redis.baander.app:6379/messages",
"CONVERT_STORAGE_PATH": "/storage/conversions",
"MEDIA_STORAGE_PATH": "/media/library"
}
}
The two database addresses must reach the same database from the host and worker
network respectively. The controller URL and credentials file stay on the host.
Docker receives the admitted runtime values through a temporary private env-file;
trusted daemon operators can inspect the resulting container environment.
Additional runtime keys are CONVERT_STORAGE_PATH, MEDIA_STORAGE_PATH,
REDIS_PASSWORD, MAILER_DSN and MAIL_FROM_ADDRESS. Without MAIL_FROM_ADDRESS,
email a worker sends comes from noreply@localhost. The sender name is always
Bånder. Values cannot contain
line breaks and their complete env-file is limited to 4 KiB.
Worker credentials admit only canonical Redis URL forms that preserve the
messages and scheduler_occurrences streams, the baander group and the
generated consumer identity. Invalid forms fail before container creation;
app:worker checks again before child launch. Errors identify the setting without
printing its credential-bearing value. See Messenger configuration
for accepted paths and query options. These checks do not establish connectivity
or Redis durability.
Apply an external deadline to every action. DBAL does not enforce a hard connection
deadline through connect_timeout; that query option is rejected here.
php bin/worker-deployment.php --help
timeout --kill-after=5s 60s php bin/worker-deployment.php create /etc/baander/worker-manifest.json /etc/baander/worker-credentials.json
timeout --kill-after=5s 60s php bin/worker-deployment.php start /etc/baander/worker-manifest.json /etc/baander/worker-credentials.json
timeout --kill-after=5s 60s php bin/worker-deployment.php status /etc/baander/worker-manifest.json /etc/baander/worker-credentials.json
timeout --kill-after=5s 60s php bin/worker-deployment.php readiness /etc/baander/worker-manifest.json /etc/baander/worker-credentials.json
The host actions are create, reconcile-create, start, status, readiness
and recover. Follow Stop, replace or recover workers
for uncertain outcomes and replacement; timeout or interruption does not cancel
a Docker operation already submitted. Keep the exact admitted manifest and
credentials for reconciliation.
status reports committed observations and always returns
readiness: "not_checked". The separate read-only readiness action verifies the
exact active manifest, lease, admitted running containers, expected direct-child
roles, Redis connectivity and execution consumer registrations. Success is a
point-in-time observation, not proof of completed work or future availability.
See Observe background work for
the distinction between deployment, heartbeat, queue and delivery evidence.
Autoscaling status reports desired, occupied, busy, idle, parked, missing, uncertain and disconnected slots, together with controller/resource freshness, sample ages, pressure and the decision reason. Unavailable evidence remains unknown; it is not an empty queue or an idle slot. See Admission and native work for the admission and drain policy.
Actions return bounded JSON on stdout. Exit codes are 0 for a confirmed result,
1 for operation_unconfirmed, 2 for invalid_configuration, and 3 for a
denied or unregistered operation. The external timeout command may return its
own exit code without JSON. Error output contains no credentials or raw diagnostics.
Auth & Users
| Command | Description |
|---|---|
| app:auth:rotate-secrets | Prepare an OAuth bundle and invalidate grants during offline cutover |
| app:auth:setup-clients | Create the first-party OAuth client that password and passkey login issue tokens to |
| app:login-block:delete | Remove one login block, or every block with --all |
| app:login-block:list | List the login honeypot's blocks, newest first |
| app:oauth:client:create | Register a device, public or confidential OAuth client |
| app:oauth:client:list | List OAuth clients other than personal access clients |
| app:oauth:client:revoke | Revoke an OAuth client and every token issued to it |
| app:oauth:client:rotate-secret | Give a confidential OAuth client a new secret |
| app:oauth:generate-keys | Generate OAuth2 private and public keys for JWT signing |
| app:oauth:purge-codes | Delete authorization and device codes that expired more than an hour ago |
| app:user:change-email | Change a user's email address and send a verification link to it |
| app:user:create | Create a new user account |
| app:user:delete | Delete a user account |
| app:user:disable | Disable a user account and end their sessions |
| app:user:enable | Enable a previously disabled user account |
| app:user:list | List users, newest first |
| app:user:rename | Change a user's display name |
| app:user:reset-password | Set a new password for a user and sign them out everywhere |
| app:user:roles | Replace a user's roles |
| app:user:setting | Show, set or reset a user's setting, such as their email language |
Server Settings
| Command | Description |
|---|---|
| app:settings:definitions | List the definition of every setting, server-wide and per-user |
| app:settings:get | Show a server setting: its value, default and stored value |
| app:settings:list | List every server setting with its value, default and enforcement |
| app:settings:reset | Reset a server setting to its default |
| app:settings:set | Set a server setting |
Libraries & Catalog
| Command | Description |
|---|---|
| app:album:cover:remove | Remove an album cover and delete its image files |
| app:album:cover:set | Set or replace an album cover from an image file in the container |
| app:album:delete | Delete an album and every song on it, optionally with the audio files |
| app:album:duplicates | List the groups of albums in a library that look like duplicates |
| app:album:extract-covers | Queue embedded cover art extraction for every album without a cover |
| app:album:merge | Merge a source album into a target album and delete the source |
| app:album:update | Change an album's metadata, or lock and unlock its fields |
| app:artist:album:add | Credit an artist on an album with a role |
| app:artist:album:remove | Remove every one of an artist's credits on an album |
| app:artist:album:role | Change the role of an artist's credit on an album |
| app:artist:cover:remove | Remove an artist cover and delete its image files |
| app:artist:cover:set | Set or replace an artist cover from an image file in the container |
| app:artist:create | Create an artist, such as a performer to credit by hand |
| app:artist:delete | Delete an artist, its credits and its cover image |
| app:artist:song:add | Credit an artist on a song with a role |
| app:artist:song:remove | Remove every one of an artist's credits on a song |
| app:artist:song:role | Change the role of an artist's credit on a song |
| app:artist:update | Change an artist's metadata, or lock and unlock its fields |
| app:genre:album:add | Assign a genre to an album |
| app:genre:album:remove | Remove a genre from an album |
| app:genre:create | Create a genre, optionally below a parent genre |
| app:genre:delete | Delete a genre; its child genres become root genres |
| app:genre:list | List every genre, or show the genre hierarchy with --tree |
| app:genre:song:add | Assign a genre to a song |
| app:genre:song:remove | Remove a genre from a song |
| app:genre:update | Rename a genre, change its slug or MusicBrainz ID, or move it below another parent |
| app:library:create | Register a new media library |
| app:library:delete | Delete a media library |
| app:library:list | List every media library |
| app:library:member:grant | Let a user see a library |
| app:library:member:list | List every library with whether a user may see it |
| app:library:member:revoke | Stop a user seeing a library |
| app:library:scan | Scan a media library, or every library, in this process |
| app:library:show | Show one media library |
| app:library:stats | Show the content counts of one media library |
| app:library:update | Rename a media library or change its sort order |
| app:library:validate-path | Check that a directory can serve as a media library |
| app:movie:delete | Delete a movie and the videos no other movie uses |
| app:movie:update | Change a movie's title, year or summary |
| app:song:delete | Delete a song, optionally with its audio file |
| app:song:update | Change a song's metadata, or lock and unlock its fields |
| app:watch-files | Watch directories for filesystem changes |
Media & Lyrics
| Command | Description |
|---|---|
| app:image:prune-missing | Delete image records whose files no longer exist in storage |
| app:image:stats | Show how many images are stored and how much space they use, by type |
| app:lyrics:apply | Store an LRCLIB search result as the lyrics of a song that has none |
| app:lyrics:coverage | Show how many tracks have lyrics, and the lyrics by source |
| app:lyrics:fetch | Queue a lyrics fetch from LRCLIB for songs without lyrics |
| app:lyrics:search | Search LRCLIB for lyrics and list the results with their IDs |
| app:lyrics:status | Show the lyrics fetch jobs of the past 7 days and how many finished or failed |
| app:song:lyrics:fetch | Fetch the lyrics of one song from LRCLIB and store them |
Metadata
| Command | Description |
|---|---|
| app:metadata:providers | List the metadata providers and whether each one is configured |
| app:metadata:status | Show track coverage, the last sync and the sync jobs by type |
| app:metadata:sync | Queue a metadata sync for every library, or only the genre sync |
Listening Activity
| Command | Description |
|---|---|
| app:activity:engagement | Show the active users and their average plays and listening time over a range of days |
| app:activity:summary | Show the plays, distinct tracks and artists, and listening time over a range of days |
| app:activity:top-artists | List the most played artists over a range of days |
| app:activity:top-tracks | List the most played tracks over a range of days |
Radio
| Command | Description |
|---|---|
| app:radio:country:list | List the countries the station directory offers, with their station counts |
| app:radio:source:create | Create a radio source that station data is synced from |
| app:radio:station:list | List the synced stations, optionally by country or search text |
| app:radio:sync | Sync stations for every country a user subscribes to |
Recommendations
| Command | Description |
|---|---|
| app:recommendation:generate | Generate music recommendations with every strategy, in this process |
| app:recommendation:job:cancel | Cancel a pending or running recommendation job |
| app:recommendation:job:list | List the most recent recommendation jobs, newest first |
| app:recommendation:job:requeue | Run a failed or cancelled recommendation job again as a new job |
| app:recommendation:job:show | Show one recommendation job: status, progress, counts and metadata |
| app:recommendation:stats | Show recommendation coverage, source quality and freshness |
Transcoding & Streaming
| Command | Description |
|---|---|
| app:qol:profile | Set the stream governor's algorithm profile in every web server worker |
| app:qol:reset | Discard the stream governor's learning data in every web server worker |
| app:qol:status | Show the stream governor status of every web server worker |
| app:qol:streams | List the streams every web server worker has admitted, with their predicted cost |
| app:transcode:cache-sweep | Delete old transcode cache directories by age and size budget |
| app:transcode:job:cleanup | Remove transcode jobs that no session uses, with their output |
| app:transcode:session:list | List the active transcode sessions of every user, or of one user |
| app:transcode:session:show | Show one transcode session |
Scheduler
| Command | Description |
|---|---|
| app:scheduler:commands | List the commands a scheduled job can run, with their parameters |
| app:scheduler:create | Create a scheduled job that runs a schedulable command on a cron schedule |
| app:scheduler:delete | Delete a scheduled job |
| app:scheduler:disable | Disable a scheduled job until it is enabled |
| app:scheduler:enable | Enable a disabled scheduled job |
| app:scheduler:list | List all scheduled jobs |
| app:scheduler:pause | Pause a scheduled job until it is resumed |
| app:scheduler:resume | Resume a paused scheduled job |
| app:scheduler:run | Record a durable manual request for a scheduled job |
| app:scheduler:show | Show a scheduled job with its schedule, parameters and last run |
| app:scheduler:update | Change a scheduled job's name, schedule, command, description or parameters |
Notifications
| Command | Description |
|---|---|
| app:generate-vapid-keys | Generate VAPID keys for push notifications |
| app:webhook:create | Add a webhook and print its signing secret, which is shown only once |
| app:webhook:delete | Delete a webhook |
| app:webhook:list | List the configured webhooks, without their secrets |
| app:webhook:rotate-secret | Replace a webhook's signing secret and print the new one once |
| app:webhook:update | Change a webhook's URL or the notification categories it receives |
Web Server & Workers
| Command | Description |
|---|---|
| app:outbox:consume | Relay durable notification events and delivery intents |
| app:server:coroutines | Show coroutine and channel statistics of every web server worker |
| app:server:drain:watch | Kill the web server processes still running at the drain deadline (started by app:serve) |
| app:server:spans | List the web server's recent request spans, or empty the span buffer |
| app:server:stats | Show memory, process and coroutine figures of every web server worker |
| app:server:workers | Show the web server's HTTP and user worker statistics |
| app:worker | Run one unit of the paired worker deployment |
System & Diagnostics
| Command | Description |
|---|---|
| app:cli:manifest | Output a JSON manifest of all CLI commands and tooling metadata |
| app:config:validate | Validate application configuration and check for misconfigurations |
| app:discovery:register | Register a self-hosted server for discovery and pairing |
| app:health:check | Check the health of all system components |
| app:monitor:analytics | Show background job analytics for a time range: summary, timing or failures |
| app:monitor:job:cancel | Cancel a running background job, or the work a finished job queued |
| app:monitor:job:retry | Dispatch a failed background job's message again under a new job ID |
| app:monitor:job:show | Show one background job with its error, stored message and run time |
| app:monitor:jobs | List background jobs with the job monitor's filters, sorting and pages |
| app:monitor:prune | Prune completed job monitors older than a given age |
| app:monitor:status | Show background job counts by status and the jobs running now |
| app:monitor:transport | Show the async queue depth, the failed message count and the consumer's registration |
| app:rate-limiter:clear | Clear the stored state of one rate limiter, or of all of them |
| app:rate-limiter:list | List every configured rate limiter with its effective configuration |
| debug:hw-transcode | Show resolved hardware encoder profile and sample FFmpeg commands |
Failed messages
Messages that exhaust their retries land in the failure transport, a PostgreSQL table. Two Baander commands work on the whole transport, including messages waiting out a retry delay, as the admin endpoints under /api/monitor/transport/failed do (see Monitoring):
| Command | Description |
|---|---|
| app:failed-message:flush | Remove every message from the failure transport |
| app:failed-message:list | List the failed messages, newest first |
Symfony's built-in commands handle one message by ID. Pass --force to skip the confirmation prompt, which non-interactive runs require.
| Command | Description |
|---|---|
messenger:failed:show <id> |
Show one failed message |
messenger:failed:retry <id> --force |
Handle a failed message again; one that fails again returns under a new ID and stays listed |
messenger:failed:remove <id> --force |
Remove one failed message |
Development & Docs
| Command | Description |
|---|---|
| app:dev:create-users | Create the standard development users |
| app:dev:setup | Bootstrap the dev environment (migrations, keys, clients, users) |
| app:e2e:ingest-video | Ingest a directory of video files as a movie library, without workers |
| app:export-openapi-spec | Export the OpenAPI spec to a file |
| app:generate-docs | Generate the documentation site from source code and docs-book/ |