On this page
Operator manual

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/