Skip to content

Configuration

Every setting can come from a TOML config file or be overridden by an environment variable (and a few by a CLI flag). This page documents the precedence, the complete environment-variable surface, the TOML config keys, and the default storage paths.

A fully commented config.example.toml ships in the repository root; copy it and edit the keys you need. The sections below cross-reference it.

Token precedence

A Musixmatch API token is required only when Musixmatch is the active provider (the default); the one-shot fetch CLI needs one supplied explicitly in that case. Petit Lyrics and the InnerTube lane are both tokenless. In serve mode with Musixmatch active, a token is optional (serve provisions and stores one itself). When you do supply a Musixmatch token, these are the sources in order of precedence (highest first):

  1. --token CLI flag - highest priority.
    canticle --token YOUR_TOKEN adele,hello
    
  2. MUSIXMATCH_TOKEN environment variable (MXLRC_API_TOKEN is accepted as a lower-precedence alias).
    export MUSIXMATCH_TOKEN=YOUR_TOKEN
    canticle adele,hello
    
  3. Config file / .env file - place a .env in the working directory where you run the command, or set the token in the TOML config.
    MUSIXMATCH_TOKEN=YOUR_TOKEN
    

In serve mode a token is optional: when none is configured, Canticle obtains one automatically on first run and persists it in the encrypted secret store, reusing it on later starts. A token you configure here always takes precedence and is never overwritten by that automatic path. The one-shot fetch CLI is stateless and has no secret store, so it cannot persist a token and still requires one.

General precedence

For all settings, precedence is CLI flag > environment variable > config file > built-in default.

Storage paths

canticle resolves its config file and SQLite database using XDG base directories by default, with overrides for Docker and native packages.

Install method Config file Database
XDG (default) $XDG_CONFIG_HOME/mxlrcgo-svc/config.toml $XDG_DATA_HOME/mxlrcgo-svc/mxlrcgo.db
Docker (MXLRC_DOCKER=true) /config/config.toml /config/mxlrcgo.db
Native package (.deb/.rpm/.apk) /etc/mxlrcgo-svc/config.toml /var/lib/mxlrcgo-svc/mxlrcgo.db

Native packages run the service as the mxlrcgo-svc system user; that user owns /var/lib/mxlrcgo-svc (mode 0750). The state directory and system user are preserved on package removal so the database survives an upgrade or reinstall.

Override any path explicitly with MXLRC_DB_PATH, MXLRC_OUTPUT_DIR, or the --config flag.

Instrumental tracks and --upgrade

Instrumental tracks always write a .txt marker file. These files are intentionally excluded from --upgrade promotion: re-fetching an instrumental would simply produce the same marker. Use --update (full re-fetch) if you want to force a re-check of an instrumental marker after a catalog change.

Environment variables

The table below is the complete env-var surface; the watcher and verification sections of the User Guide give the operational detail.

Variable Default Purpose
MUSIXMATCH_TOKEN (auto-provisioned in serve mode; required for fetch) Musixmatch API token. MXLRC_API_TOKEN is accepted as a lower-precedence alias.
MXLRC_WEBHOOK_API_KEY (none) Comma-separated webhook API key(s) accepted by the server. Generate with canticle keys create --scope webhook.
MXLRC_SERVER_ADDR 127.0.0.1:3876 HTTP listen address for serve. Docker images default this to 0.0.0.0:50705.
MXLRC_WEB_UI_ENABLED false Enable the browser UI on the serve listener. Env override of web_ui_enabled (precedence: env > file). Restart to apply.
MXLRC_TRUSTED_CIDRS (none) Comma-separated CIDRs of trusted client networks (serve mode). Loopback is always trusted. Requests from listed CIDRs may scrape GET /metrics and bypass the web UI session requirement.
MXLRC_TRUSTED_PROXIES (none) Comma-separated CIDRs of reverse proxies whose X-Forwarded-For is trusted to carry the real client IP (serve mode). Must not overlap MXLRC_TRUSTED_CIDRS.
MXLRC_TLS_CERT_FILE (none) PEM certificate path for the serve listener. Set with MXLRC_TLS_KEY_FILE to terminate TLS (minimum TLS 1.2).
MXLRC_TLS_KEY_FILE (none) PEM private key path. Required together with MXLRC_TLS_CERT_FILE.
MXLRC_TLS_SELF_SIGNED false Generate and persist a self-signed certificate on first run under <dir(db_path)>/tls/. Mutually exclusive with the cert/key pair.
MXLRC_TLS_REDIRECT_HTTP (none) Plain-HTTP listen address (e.g. :80) that 301-redirects to HTTPS. Honored only when TLS is enabled.
MXLRC_TLS_SELF_SIGNED_HOSTS (none) Comma-separated extra SAN hostnames/IPs for the self-signed certificate (on top of localhost, canticle, 127.0.0.1, ::1). Honored only when self-signed.
MXLRC_OUTPUT_DIR XDG / /music Output directory for fetch mode. Ignored in serve mode (lyrics are written next to the audio file; the metadata-only webhook fallback uses the internal default).
MXLRC_EMBEDDED_LYRICS off Embedded lyrics handling. off - ignore (default); respect - skip fetching when embedded lyrics exist; extract - write embedded lyrics to a sidecar, then skip fetching. A timestamped Vorbis SYNCEDLYRICS comment is written as a synced .lrc (preferred); unsynced lyrics become .txt. ID3 SYLT / MP4 atoms are not handled.
MXLRC_BILINGUAL_OUTPUT false When true and a provider returns a translation track, interleave original and translated lines under shared timestamps in a single .lrc.
MXLRC_DB_PATH XDG / /config/mxlrcgo.db SQLite database path.
MXLRC_DOCKER false When true, storage defaults resolve under /config. Set automatically in the images.
MXLRC_MASTER_KEY (none) Optional. Base64 of 32 random bytes; overrides the auto-generated key file as the master key for encrypted-at-rest secrets. When set, no key file is read or written. Use for key/data separation (recommended Docker hardening when the threat model includes whole-volume theft). Generate with openssl rand -base64 32. See the Encrypted secrets guide.
MXLRC_SECRETS_KEY_FILE XDG / /config/.mxlrcgo.key Override for the auto-generated 0600 key-file location (the native-install default; used only when MXLRC_MASTER_KEY is unset). Point it at a separate mount to keep the key off the data volume.
MXLRC_WEBAUTH_ADMIN_USER (none) Bootstrap-only. With MXLRC_WEBAUTH_ADMIN_PASSWORD, creates the first web-UI admin on startup when none exists (skipped otherwise). Both must be set; an existing admin is never overwritten. Rotate the password after first run and remove these vars. See Web UI access.
MXLRC_WEBAUTH_ADMIN_PASSWORD (none) Bootstrap-only. The first web-UI admin password (at least 8 characters; a shorter value is a fatal startup error). Never logged. Pairs with MXLRC_WEBAUTH_ADMIN_USER.
MXLRC_API_COOLDOWN 15 Seconds between Musixmatch requests. MXLRC_COOLDOWN is a lower-precedence alias.
MXLRC_API_CIRCUIT_OPEN_DURATION 1800 Cap (seconds) for the worker circuit-breaker window; the window ramps geometrically up to this ceiling, and a token-renewal signal opens for the full cap (floor 300).
MXLRC_API_CIRCUIT_BACKOFF_BASE 60 Trip-1 circuit-breaker window (seconds); doubles each consecutive throttle up to MXLRC_API_CIRCUIT_OPEN_DURATION, resets on a successful fetch or clean miss (floor 15, capped at the open-duration).
MXLRC_MISS_BACKOFF_BASE_HOURS 168 Initial re-check delay in hours for a benign miss (7 days). Doubles on each successive miss up to MXLRC_MISS_BACKOFF_CAP_HOURS. Minimum 1.
MXLRC_MISS_BACKOFF_CAP_HOURS 672 Maximum re-check delay in hours for a benign miss (28 days). Clamped to at least MXLRC_MISS_BACKOFF_BASE_HOURS.
MXLRC_MAX_MISS_ATTEMPTS 15 Maximum number of miss re-fetches before retiring the queue row. 0 means no cap.
MXLRC_SCAN_INTERVAL 900 DEPRECATED (see MXLRC_SCAN_SCHEDULE). serve library-scan interval in seconds, measured from process start. 0 scans once without repeating.
MXLRC_SCAN_SCHEDULE (unset) Wall-clock scan schedule: hourly, daily, weekly, or off. Unset keeps the deprecated MXLRC_SCAN_INTERVAL behavior.
MXLRC_SCAN_SCHEDULE_AT (unset) Start time for the daily/weekly scan, HH:MM on a 24-hour local clock.
MXLRC_SCAN_SCHEDULE_DAY (unset) Weekday for the weekly scan (sunday..saturday).
MXLRC_SCAN_SCHEDULE_ON_START false Also scan the whole library at startup.
MXLRC_WORK_INTERVAL 0 Worker poll interval in seconds. 0 falls back to api.cooldown (15s floor).
MXLRC_PROVIDER_PRIMARY musixmatch Primary lyrics provider.
MXLRC_PROVIDERS_DISABLED (none) Comma-separated providers to disable.
MXLRC_PROVIDERS_MODE ordered Multi-provider dispatch strategy: ordered (first suitable result in priority order) or parallel (race every lane).
MXLRC_PROVIDERS_RACE_WAIT_SECONDS 2 Parallel-mode synced-upgrade window in seconds. Ignored in ordered mode.
MXLRC_PROVIDERS_FALLBACK_ORDER (none) Comma-separated providers consulted, in order, after the primary returns no suitable result. Empty means primary-only.
MXLRC_PROVIDERS_PETITLYRICS_COOLDOWN_SECONDS 0 Minimum seconds between Petit Lyrics requests. 0 falls back to api.cooldown.
MXLRC_PROVIDERS_INNERTUBE_COOLDOWN_SECONDS 0 Minimum seconds between InnerTube requests. 0 falls back to api.cooldown.
MXLRC_GUARD_ACCEPTED_SCRIPTS (none) Comma-separated allowlist of Unicode script buckets a lyric body may use (Latin, Han, Kana, Hangul, Other). Empty disables the language/script guard.
MXLRC_GUARD_THRESHOLD 0.20 Maximum tolerated share of foreign-script letters before a result is rejected. Values outside (0, 1] reset to the default.
MXLRC_QUEUE_RANDOMIZE true Shuffle worker dequeue order within each priority tier (anti-fingerprint). false restores deterministic order.
MXLRC_QUEUE_BATCH_SIZE 10 Shuffled lookahead buffer size: draw N eligible rows at random per batch and serve them in order, so the near-term queue is knowable. 0 disables batching (per-item random). Applies only when randomize = true.
MXLRC_LOG_LEVEL info Minimum log level: debug, info, warn, error. debug exposes per-request detail, worker idle-polls, and watcher events.
MXLRC_LOG_FORMAT text Log output format: text (human-readable) or json (structured, for log aggregators).
MXLRC_LOG_FILE (none) Log file path. Empty means console-only (stderr). When set, logs go to both stderr and the file with automatic rotation.
MXLRC_LOG_MAX_SIZE_MB 10 Maximum log file size in megabytes before rotation.
MXLRC_LOG_MAX_FILES 5 Number of rotated log files to retain.
MXLRC_LOG_MAX_AGE_DAYS 30 Maximum age in days of retained rotated log files.
MXLRC_LOG_COMPRESS true Compress rotated log files with gzip.
MXLRCGO_WATCH_ENABLED false Enable the optional low-latency filesystem watcher (see the User Guide).
MXLRCGO_WATCH_DEBOUNCE_MS 2000 Watcher debounce window in milliseconds.
MXLRCGO_WATCH_MAX_DIRS 100000 Watcher safety cap on directories watched.
MXLRC_VERIFICATION_ENABLED false Enable Whisper-based lyric verification (requires a sidecar and ffmpeg).
MXLRC_VERIFICATION_WHISPER_URL (none) Whisper-compatible transcription endpoint. MXLRC_WHISPER_URL is an alias.
MXLRC_VERIFICATION_FFMPEG_PATH ffmpeg Path to the ffmpeg binary used to extract audio samples.
MXLRC_VERIFICATION_SAMPLE_DURATION_SECONDS 30 Audio sample length sent to Whisper. MXLRC_VERIFICATION_SAMPLE_DURATION is an alias.
MXLRC_VERIFICATION_MIN_CONFIDENCE 0.85 Below this Musixmatch confidence, verify against Whisper (0-1).
MXLRC_VERIFICATION_MIN_SIMILARITY 0.35 Minimum transcript/lyric overlap to accept (0-1).
MXLRC_INSTRUMENTAL_DETECTOR_ENABLED false Enable the audio-based instrumental detection sidecar. Requires MXLRC_INSTRUMENTAL_DETECTOR_CLASSIFIER_URL.
MXLRC_INSTRUMENTAL_DETECTOR_CLASSIFIER_URL (none) Base URL of the AudioSet classifier sidecar, e.g. http://yamnet:8080. Required when the detector is enabled.
MXLRC_INSTRUMENTAL_DETECTOR_FFMPEG_PATH ffmpeg Path to the ffmpeg binary used for audio sampling by the instrumental detector.
MXLRC_INSTRUMENTAL_DETECTOR_SAMPLE_DURATION_SECONDS 30 Length of the audio sample sent to the classifier, clamped to [30, 60].
MXLRC_INSTRUMENTAL_DETECTOR_MIN_CONFIDENCE 0.90 Minimum summed AudioSet class probability to mark a track instrumental. Values outside (0, 1] reset to 0.90.
MXLRC_INSTRUMENTAL_DETECTOR_CLASSES Music,Musical instrument Comma-separated AudioSet class names whose probabilities are summed and compared against the confidence threshold.
MXLRC_INSTRUMENTAL_DETECTOR_COOLDOWN_SECONDS 5 Minimum gap in seconds between consecutive classifier inference calls. 0 disables the cooldown.
MXLRC_INSTRUMENTAL_DETECTOR_VOCAL_CLASSES Singing,Vocal music,Choir,... Comma-separated AudioSet vocal-class names whose peak (max-over-frames) score blocks an instrumental marking (the vocal gate).
MXLRC_INSTRUMENTAL_DETECTOR_VOCAL_MAX_CONFIDENCE 0.015 Maximum vocal-class peak (0-1) tolerated before a track is excluded from instrumental. Values outside (0, 1] reset to 0.015.
MXLRC_INSTRUMENTAL_DETECTOR_SPREAD_SAMPLES 6 Number of short windows spread across the track and concatenated into one classifier sample. < 2 disables spreading (single contiguous window).
MXLRC_INSTRUMENTAL_DETECTOR_FFPROBE_PATH (auto-discover) Path to ffprobe used to read track duration for spread-sample placement. Empty auto-discovers (sibling of ffmpeg, then PATH). Set this when ffmpeg was auto-provisioned (no ffprobe).
MXLRC_ENRICHMENT_ENABLED true Global default for recording enrichment (reading ISRC, MusicBrainz ID, and duration from audio tags). Per-library and per-run flags override this.
MXLRC_REALIGN_ENABLED false Master switch for serve-mode reactive realign (watcher/webhook/post-scan). The CLI command runs regardless.
MXLRC_REALIGN_ON_SCAN false Also run a library-wide realign after each scan (watcher/webhook triggers are governed by enabled alone).
MXLRC_REALIGN_REQUIRE_PROVENANCE false Only apply exact ISRC/MBID matches; heuristic candidates are reported but skipped.
MXLRC_REALIGN_CROSS_DIRECTORY false Allow an exact match to move a sidecar across directories within a library.
MXLRC_REALIGN_IDENTITY_KEYS mbid,isrc Ordered provenance identifiers the exact tier matches on (valid: mbid, isrc).
MXLRC_REALIGN_MIN_CONFIDENCE 0.75 Jaro-Winkler name-guard floor (0-1) for a heuristic rename.
MXLRC_REALIGN_AUTO_APPLY_HEURISTIC false Whether serve-mode reactive realign may auto-apply heuristic (name-similarity) matches; off = exact-only.
MXLRC_REALIGN_NAME_MATCH false Enable the N:M name-similarity matcher for directories with multiple orphans and multiple sidecar-less audio files.
MXLRC_REALIGN_MIN_MARGIN 0.05 Minimum score separation (0-1) an N:M candidate's best score must hold over its runner-up to be accepted; a near-tie is reported ambiguous.
MXLRC_TIMING_VALIDATION_ENABLED false Master switch for the serve-mode timing sweep; the revalidate CLI runs regardless.
MXLRC_TIMING_VALIDATION_REVALIDATE_EXISTING false Drain the backlog of .lrc files written before the accept-time timing guard existed.
MXLRC_TIMING_VALIDATION_REVALIDATE_BATCH 100 Sidecars judged per sweep cycle; values below 1 reset to the default.
MXLRC_TIMING_VALIDATION_ON_MIS_SYNCED demote Action for a lyric whose cues overrun the audio: demote, quarantine, purge, off.
MXLRC_TIMING_VALIDATION_ON_CATEGORICAL quarantine Action for a lyric belonging to a different song: quarantine, purge, off.
PUID / PGID 99 / 100 Container-only: user/group the process drops to for file ownership.

TOML config keys

The TOML config file mirrors the environment variables in named sections. The keys below correspond one-to-one with config.example.toml, which carries inline documentation for each.

[api]

[api]
cooldown = 15
circuit_open_duration = 1800
# circuit_backoff_base_seconds = 60
# miss_backoff_base_hours = 168   # initial re-check delay (hours; 7 days); minimum 1
# miss_backoff_cap_hours = 672    # maximum re-check delay (hours; 28 days)
# max_miss_attempts = 15          # 0 = no cap

Request cooldown, the worker circuit-breaker window, and miss re-check backoff (env: MXLRC_API_COOLDOWN, MXLRC_API_CIRCUIT_OPEN_DURATION, MXLRC_API_CIRCUIT_BACKOFF_BASE, MXLRC_MISS_BACKOFF_BASE_HOURS, MXLRC_MISS_BACKOFF_CAP_HOURS, MXLRC_MAX_MISS_ATTEMPTS).

miss_backoff_base_hours (default 168, i.e. 7 days) and miss_backoff_cap_hours (default 672, i.e. 28 days) govern the escalating re-check cadence for benign misses: the delay doubles each miss (base, 2 x base, 4 x base, ...) up to the cap. max_miss_attempts retires the queue row after N miss fetches; 0 means no cap.

[output]

[output]
dir = "lyrics"
# embedded_lyrics = "off"
# bilingual_output = false
# word_sync = false

Fallback output directory and per-file output controls (env: MXLRC_OUTPUT_DIR, MXLRC_EMBEDDED_LYRICS, MXLRC_BILINGUAL_OUTPUT, MXLRC_WORD_SYNC; CLI: --embedded-lyrics).

embedded_lyrics controls how lyrics already embedded in the audio file's tags are handled. off (default) ignores them and always fetches from providers. respect skips fetching for files that already carry embedded lyrics. extract writes the embedded lyrics to a sidecar (never overwriting an existing one) and then skips fetching: a Vorbis SYNCEDLYRICS comment carrying timestamped LRC text is written as a synced .lrc and takes precedence over unsynced lyrics, which are written as .txt. ID3 SYLT and MP4 synced-lyric atoms are intentionally not handled.

bilingual_output (default false): when true and a provider returns a non-empty translation track, the original and translation lines are interleaved under shared timestamps in a single .lrc. See docs/multilingual-output-policy.md.

word_sync (default false): when true and a provider serves word-level timings -- today only Petit Lyrics' word-synced tier -- each cue keeps its normal [MM:SS.cc] stamp and gains a <MM:SS.cc> marker before each word (Enhanced LRC, "A2"). This is the karaoke-style per-word highlighting some players support.

Leave this off unless you know your player handles A2. Support is not universal and there are three outcomes, only one of which is obviously wrong: the player highlights each word (the intent), silently ignores the markers (harmless), or renders them as literal text mixed into the lyrics (visibly broken). Music Assistant does not support A2; Symfonium ships marker stripping. Verify against one album before enabling library-wide.

Word markers are emitted per line and only when they are trustworthy. A line whose timings do not reconstruct its text, whose words all share one timestamp, or whose text would break the line format falls back to a normal line-level cue -- so a partially-timed track degrades line by line rather than losing its words. Enabling this does not rewrite existing sidecars; a track gains markers on its next fetch.

[db]

[db]
# path = "mxlrcgo.db"

SQLite database path (env: MXLRC_DB_PATH).

[server]

[server]
addr = "127.0.0.1:3876"
# webhook_api_keys = ["mxlrc_your-webhook-key"]
# scan_interval_seconds = 900
# work_interval_seconds = 0
# web_ui_enabled = false

HTTP listen address, webhook keys, and the scheduler scan/worker poll intervals (env: MXLRC_SERVER_ADDR, MXLRC_WEBHOOK_API_KEY, MXLRC_SCAN_INTERVAL, MXLRC_WORK_INTERVAL; CLI: --listen, --scan-interval, --work-interval).

scan_interval_seconds is deprecated in favor of [server.scan_schedule] below. It still works, and still takes effect whenever scan_schedule.frequency is blank, so an existing config keeps running unchanged.

web_ui_enabled (default false, env: MXLRC_WEB_UI_ENABLED, precedence env > file) gates the browser UI on the serve listener. When enabled, the UI pages require a session login (a single admin account, separate from the webhook API key), or a request from a trusted network (the [server.trusted_networks] CIDR allowlist). Secret values (API token, webhook keys) are always redacted in the Config view. See Web UI access for the first-run onboarding flow.

[server.scan_schedule]

[server.scan_schedule]
frequency = "daily"     # hourly | daily | weekly | off
at = "04:00"            # daily/weekly start time, 24-hour local clock
day = "sunday"          # weekly only
scan_on_start = false   # also walk the library at startup

The wall-clock schedule for the serve-mode library scan, and the successor to scan_interval_seconds (env: MXLRC_SCAN_SCHEDULE, MXLRC_SCAN_SCHEDULE_AT, MXLRC_SCAN_SCHEDULE_DAY, MXLRC_SCAN_SCHEDULE_ON_START).

Why it replaces an interval. scan_interval_seconds started its timer at process start, which is a duration-from-boot rather than a schedule. Any supervisor that restarts the process more often than the interval - a nightly backup that stops the container, a watchdog, a routine deploy - reset the timer before it could fire. The result inverted the setting's intent: the knob meant to scan less often instead produced one full library walk on every restart, and the periodic scan never ran at all. A wall-clock anchor cannot be inverted that way, because "daily at 04:00" means the same thing however often the process restarts.

frequency - hourly fires at the top of each hour and needs no at. daily and weekly fire at the next occurrence of the configured wall-clock time. off disables periodic scanning entirely (the filesystem watcher and any manual scan run are unaffected). Blank is not the same as off: blank means "not configured", which keeps the deprecated scan_interval_seconds path alive so that upgrading changes nothing for an existing deployment. Set off when you actually want no periodic scan.

at - the daily/weekly anchor, HH:MM on a 24-hour local clock. Required for daily and weekly; a missing or malformed anchor is a startup error, not a silent default, since a scan firing at a time nobody chose is the failure this whole surface exists to prevent.

day - the weekly anchor, a lowercase weekday name. Required for weekly only.

scan_on_start - default false. The filesystem watcher already covers change-driven rescans, so the periodic walk is a backstop and a restart should not cost a full library pass. Set it true if you want the old always-scan-at-boot behavior. The deprecated interval path keeps its historical startup walk regardless of this field.

Missed windows are skipped, not run late. If the machine was asleep or the container was down through the scheduled time, the scan does not run on the way back up - it waits for the next occurrence. Running it late would restore precisely the "every restart triggers a full walk" behavior this replaces.

Daylight saving. The anchor is a wall-clock time, so daily at 04:00 stays 04:00 local on both sides of a transition; the interval between those two runs is 23 or 25 hours rather than 24. An anchor inside the hour that spring-forward skips still runs once, at the adjacent real instant, rather than being missed for the year.

[server.trusted_networks]

[server.trusted_networks]
# cidrs = ["192.168.1.0/24", "10.0.0.0/8"]
# trusted_proxies = ["172.16.0.0/12"]

Trusted-network allowlist for serve mode (env: MXLRC_TRUSTED_CIDRS, MXLRC_TRUSTED_PROXIES). Controls two things: access to GET /metrics (see the User Guide) and the web UI session bypass (requests from a trusted network skip the login prompt).

cidrs - CIDRs of trusted client networks. Loopback (127.x.x.x, ::1) is always trusted and does not need to be listed. An empty list (the default) means only loopback is trusted.

trusted_proxies - CIDRs of reverse proxies allowed to set X-Forwarded-For. Only when a request's immediate peer is within one of these networks is the XFF header consulted (walked right-to-left, skipping proxies) to find the real client IP. Default empty: XFF is never trusted, so a spoofed header cannot forge a trusted source. Entries must not overlap cidrs.

An invalid CIDR in either list is a fatal startup error.

Web UI access

The first time the UI is enabled there is no admin yet, so every UI page redirects to /setup. The onboarding form creates the admin account and can optionally store the Musixmatch token and webhook API key in the encrypted secret store.

/setup is reachable to any client that can reach the port until the first admin exists (#461). Once one does, the page closes permanently: a trusted client is redirected to /login, and an untrusted one gets a 404 that does not reveal the endpoint. The window is therefore self-closing and cannot reopen -- admin creation is a first-writer-wins atomic insert, so exactly one claim can succeed.

This is a deliberate trade. Gating /setup on the trusted-network list previously meant that bootstrapping required adding your browser's network to that list, and a trusted CIDR bypasses the session check on every UI route, permanently -- so you had to grant an indefinite, network-wide authentication bypass just to create your first account, and it stayed on unless you remembered to remove it. Keying the setup page on admin-existence instead bounds the exposure to the pre-onboarding period. Canticle logs a warning each time it serves the form to a client the trust policy would not have admitted.

Close the window immediately on an exposed network by bootstrapping the admin at startup with MXLRC_WEBAUTH_ADMIN_USER / MXLRC_WEBAUTH_ADMIN_PASSWORD (below), which creates the account before the listener ever serves /setup. Otherwise, create your admin account promptly on first start.

The webhook API and health endpoints are unaffected before onboarding - an existing deployment with a webhook key and no admin keeps processing the queue and serving webhooks; only the browsable UI is gated.

For headless/Docker deployments, set both MXLRC_WEBAUTH_ADMIN_USER and MXLRC_WEBAUTH_ADMIN_PASSWORD to bootstrap the admin at startup instead of using the form. This is idempotent (an existing admin is never overwritten), the password (minimum 8 characters) is never logged, and a too-short password is a fatal startup error. Treat them as bootstrap-only: sign in and rotate the password after first run, then drop the variables.

[server.tls]

[server.tls]
# cert_file = "/config/tls/cert.pem"
# key_file = "/config/tls/key.pem"
# self_signed = false
# redirect_http = ""
# self_signed_hosts = []

Optional transport security for the serve listener. TLS is off by default (plain HTTP). Two modes are supported and are mutually exclusive: bring-your-own certificate (cert_file + key_file, both required together) or a self-signed bootstrap (self_signed = true). A contradictory combination is a fatal startup error (env: MXLRC_TLS_CERT_FILE, MXLRC_TLS_KEY_FILE, MXLRC_TLS_SELF_SIGNED, MXLRC_TLS_REDIRECT_HTTP, MXLRC_TLS_SELF_SIGNED_HOSTS).

Key Default Purpose
cert_file (none) PEM-encoded certificate path. With key_file, the listener terminates TLS itself (minimum TLS 1.2).
key_file (none) PEM-encoded private key path. Required together with cert_file.
self_signed false Generate and persist a self-signed certificate on first run (ECDSA P-256, ~365-day validity) under <dir(db_path)>/tls/, regenerating when missing or expired. Mutually exclusive with cert_file/key_file. Browsers show an untrusted-certificate prompt.
redirect_http (none) Optional plain-HTTP listen address (e.g. :80) whose every request 301-redirects to the HTTPS address. Empty means no redirect listener. Honored only when TLS is enabled.
self_signed_hosts [] Extra hostnames and IP literals to add as Subject Alternative Names in the generated certificate, on top of the built-in SANs (localhost, canticle, 127.0.0.1, ::1). Invalid entries are a startup error. Honored only when self_signed is true.

TLS is considered enabled when either a cert_file+key_file pair is set or self_signed is true; otherwise the listener stays plain HTTP.

[secrets]

[secrets]
# key_file = ""

Key-file location override for encrypted-at-rest secrets (env: MXLRC_SECRETS_KEY_FILE). Empty uses the default (the hidden .mxlrcgo.key beside the database, auto-generated 0600 on first use). The master key is resolved from MXLRC_MASTER_KEY first (when set, the key file is skipped entirely), then from this key file; it is never stored in the config. See the Encrypted secrets guide for the secrets import / set / list commands and key-loss recovery.

[providers]

[providers]
primary = "musixmatch"
disabled = []
# mode = "ordered"          # "ordered" (default) or "parallel"
# race_wait_seconds = 2     # parallel-mode synced-upgrade window
# fallback_order = []       # providers consulted after the primary, in order
# petitlyrics_cooldown_seconds = 0   # 0 uses api.cooldown
# innertube_cooldown_seconds = 0     # 0 uses api.cooldown

Provider selection and multi-provider dispatch. Musixmatch is the default primary provider; Petit Lyrics and InnerTube are the two alternate lanes, selectable as primary or added to fallback_order (env: MXLRC_PROVIDER_PRIMARY, MXLRC_PROVIDERS_DISABLED, MXLRC_PROVIDERS_MODE, MXLRC_PROVIDERS_RACE_WAIT_SECONDS, MXLRC_PROVIDERS_FALLBACK_ORDER, MXLRC_PROVIDERS_PETITLYRICS_COOLDOWN_SECONDS, MXLRC_PROVIDERS_INNERTUBE_COOLDOWN_SECONDS). See Multi-provider orchestration for the full dispatch model.

Key Default Purpose
primary musixmatch The primary lyrics provider, always the first lane.
disabled [] Provider names to disable.
mode ordered Dispatch strategy. ordered queries lanes in priority order (primary, then each fallback_order entry) and returns the first suitable result. parallel dispatches every lane concurrently and races them. Any other value is rejected at load.
race_wait_seconds 2 Parallel-mode only: after a suitable unsynced result arrives, wait up to this many seconds for a synced result (a strict quality upgrade) to preempt it. Non-positive values are reset to the default. Ignored in ordered mode.
fallback_order [] Provider names consulted, in order, after the primary when it returns no suitable result. Each name must be a known provider; unknown names are rejected at load. Empty means no fallback (only the primary lane runs).
petitlyrics_cooldown_seconds 0 Minimum seconds between Petit Lyrics requests. 0 falls back to api.cooldown, which is the behavior this lane had before the key existed. The two providers share neither a rate-limit budget nor a throttle profile, so this lets you pace them independently. The Petit Lyrics client enforces its own floor (10s) on any positive value, so this key can raise the interval but never lower it past that floor.
innertube_cooldown_seconds 0 Minimum seconds between InnerTube requests. 0 falls back to api.cooldown. A negative value is not a disable request, and what it does depends on how it is set: accepted and treated as a fallback to api.cooldown when written in the TOML file, but rejected on the env-override path (MXLRC_PROVIDERS_INNERTUBE_COOLDOWN_SECONDS warns and keeps the current value) and by canticle config set (which returns an error). The InnerTube client raises any positive value below its own 2s floor up to that floor; note the clamp does not apply to 0, so with this key unset and api.cooldown also 0 the lane is unpaced. Precedence is innertube_cooldown_seconds > api.cooldown, same pattern as petitlyrics_cooldown_seconds. The interval is counted per outbound request, and a lookup on this lane costs one (candidate rejected at the search), two (no lyrics tab), or three (search, next, browse, only when it serves a result); on a fallback lane, whose traffic is mostly misses, the typical lookup costs one, the same as the other providers.

parallel mode makes more upstream calls (every lane is queried per dispatch), so it is not advised against rate-limited providers unless latency matters more than call volume.

[verification]

[verification]
enabled = false
whisper_url = ""
ffmpeg_path = "ffmpeg"
sample_duration_seconds = 30
min_confidence = 0.85
min_similarity = 0.35

Optional Whisper-based speech-to-text verification for low-confidence scanned audio. When enabled, the worker extracts a bounded mono 16 kHz WAV sample using sample_duration_seconds, then sends it to a Whisper-compatible /v1/audio/transcriptions sidecar for audio whose Musixmatch metadata confidence is below min_confidence. The transcript must overlap the candidate lyrics by at least min_similarity. Environment variables override the TOML keys (MXLRC_VERIFICATION_*); MXLRC_WHISPER_URL and MXLRC_VERIFICATION_SAMPLE_DURATION remain accepted as legacy aliases.

ffmpeg (used to extract the audio sample) is resolved automatically: see ffmpeg resolution below. Set ffmpeg_path only to pin a specific binary.

[instrumental_detector]

[instrumental_detector]
# enabled = false
# classifier_url = "http://yamnet:8080"
# ffmpeg_path = "ffmpeg"
# sample_duration_seconds = 30
# min_confidence = 0.90
# instrumental_classes = ["Music", "Musical instrument"]
# vocal_classes = ["Singing", "Vocal music", "Choir", "A capella", "Chant", "Rapping", "Child singing", "Synthetic singing", "Yodeling", "Humming"]
# vocal_max_confidence = 0.015
# speech_classes = ["Speech"]
# speech_max_confidence = 0.20
# spread_samples = 6
# ffprobe_path = ""
# cooldown_seconds = 5

# [instrumental_detector.backfill]
# enabled = true
# batch_size = 100
# interval_minutes = 60
# cooldown_seconds = 0

Optional audio-based instrumental detection sidecar (env: MXLRC_INSTRUMENTAL_DETECTOR_ENABLED, MXLRC_INSTRUMENTAL_DETECTOR_CLASSIFIER_URL, MXLRC_INSTRUMENTAL_DETECTOR_FFMPEG_PATH, MXLRC_INSTRUMENTAL_DETECTOR_SAMPLE_DURATION_SECONDS, MXLRC_INSTRUMENTAL_DETECTOR_MIN_CONFIDENCE, MXLRC_INSTRUMENTAL_DETECTOR_CLASSES, MXLRC_INSTRUMENTAL_DETECTOR_VOCAL_CLASSES, MXLRC_INSTRUMENTAL_DETECTOR_VOCAL_MAX_CONFIDENCE, MXLRC_INSTRUMENTAL_DETECTOR_SPEECH_CLASSES, MXLRC_INSTRUMENTAL_DETECTOR_SPEECH_MAX_CONFIDENCE, MXLRC_INSTRUMENTAL_DETECTOR_SPREAD_SAMPLES, MXLRC_INSTRUMENTAL_DETECTOR_FFPROBE_PATH, MXLRC_INSTRUMENTAL_DETECTOR_COOLDOWN_SECONDS).

The [instrumental_detector.backfill] sub-table configures the serve-mode sweep that classifies tracks the detector has never scored (env: MXLRC_INSTRUMENTAL_DETECTOR_BACKFILL_ENABLED, MXLRC_INSTRUMENTAL_DETECTOR_BACKFILL_BATCH_SIZE, MXLRC_INSTRUMENTAL_DETECTOR_BACKFILL_INTERVAL_MINUTES, MXLRC_INSTRUMENTAL_DETECTOR_BACKFILL_COOLDOWN_SECONDS). It is on by default -- the work is local and bounded, and without it an install that never runs scan reconcile-instrumental accumulates never-scored tracks indefinitely. batch_size bounds one cycle, interval_minutes spaces the cycles, and backfill.cooldown_seconds (separate from the detector's own, and 0 by default) is what governs how long a cycle takes. See Taming it on a low-wattage or shared host for the measured per-track costs and the recipes -- in particular, bound the sweep between cycles rather than within one, or the library disks stay awake longer to do the same work.

When enabled and a classifier_url is set, the detector samples each track's audio with ffmpeg and sends the sample to an external AudioSet classifier (a YAMNet sidecar; vendored at deploy/yamnet-detector/). In the interactive worker path it runs only on provider misses and never overrides provider-supplied data; the [instrumental_detector.backfill] sweep above is the exception, running independently of any provider miss to classify tracks the detector has never scored. A track is marked instrumental only when all three gates pass: the music gate (summed mean probability of the instrumental_classes meets min_confidence), the sung-vocal gate (no vocal_classes member peaks at or above vocal_max_confidence), and the speech gate (#403 -- the frame mean of speech_classes stays below speech_max_confidence; Speech moved out of vocal_classes into its own mean-based gate so sustained speech blocks while brief incidental speech does not). The decision is conservative: any doubt resolves to "not instrumental", because a false instrumental suppresses a real lyrics fetch.

To catch vocals that enter after an instrumental intro (arias, jazz, classical), the detector samples spread_samples short windows spread across the whole track, concatenated into one sample, and gates on the per-class max-over-frames peak (the loudest singing moment), which the frame mean dilutes. This requires the sidecar to return {"mean": {...}, "max": {...}}; a legacy mean-only sidecar degrades safely to never-instrumental.

sample_duration_seconds is clamped to [30, 60]. min_confidence and vocal_max_confidence values outside (0, 1] reset to 0.90 and 0.015 respectively. cooldown_seconds is the minimum gap between consecutive inference calls; 0 disables the cooldown. Track-duration probing needs ffprobe; the auto-provisioned ffmpeg ships none, so set ffprobe_path (or rely on a PATH ffprobe) or the detector falls back to a single window. Deploy order: upgrade Canticle before the sidecar (new Canticle tolerates the old flat-map response; old Canticle cannot parse {mean,max}). See Instrumental Detection for the full reference (decision model, sidecar setup, and tuning), and the Instrumental detection guide for the per-library and per-run override controls.

ffmpeg resolution is shared with [verification]; see ffmpeg resolution below. Set ffmpeg_path here only to pin a binary separately from the verification path.

[enrichment]

[enrichment]
enabled = true

Global default for recording enrichment (env: MXLRC_ENRICHMENT_ENABLED). When enabled, the scanner reads the ISRC, MusicBrainz recording ID, and audio duration from each file's tags and passes them to the matcher to disambiguate results.

Default true, preserving the always-on behavior from before per-library control existed. Per-library and per-run flags override this; see Recording enrichment for the full precedence chain.

[realign]

[realign]
enabled = false
on_scan = false
require_provenance = false
cross_directory = false
identity_keys = ["mbid", "isrc"]
min_confidence = 0.75
name_match = false
min_margin = 0.05

Governs the realign command, which re-attaches orphaned .lrc / .txt sidecars to renamed audio via a confidence resolver (see Realign), and serve mode's reactive realign that runs it automatically. Defaults are conservative: the feature is off, provenance is not required, matches are confined to the orphan's directory, identity is matched MBID-first then ISRC, and the N:M name matcher is off.

When enabled, serve mode runs a scoped realign on three triggers: the filesystem watcher's directory-change events, Lidarr rename/import/upgrade webhooks (complementing contrib/lidarr-rename-sidecars.sh; the webhook sweeps both the new and the old/previous directories, so a manual import that strands a sidecar self-heals), and -- when on_scan is also set -- after each library scan. Reactive realign always writes a JSONL backup (<db-dir>/realign-serve-backup.jsonl), never clobbers an existing sidecar, and auto-applies only exact provenance (ISRC/MBID) matches unless auto_apply_heuristic is set. Note that relocating a sidecar to audio in a different directory additionally requires cross_directory and a provenance tag embedded in the sidecar.

Key Env Default Meaning
enabled MXLRC_REALIGN_ENABLED false Master switch for serve-mode reactive realign (watcher / webhook / post-scan). The realign CLI command runs regardless.
on_scan MXLRC_REALIGN_ON_SCAN false Also run a library-wide realign after each periodic or manual scan (the watcher and webhook triggers are governed by enabled alone).
require_provenance MXLRC_REALIGN_REQUIRE_PROVENANCE false Only apply exact (ISRC/MBID) matches; heuristic candidates are reported but skipped.
cross_directory MXLRC_REALIGN_CROSS_DIRECTORY false Allow an exact match to move a sidecar to an audio file in a different directory within the same library.
identity_keys MXLRC_REALIGN_IDENTITY_KEYS ["mbid","isrc"] Ordered provenance identifiers the exact tier matches on, most authoritative first. Valid values: mbid, isrc.
min_confidence MXLRC_REALIGN_MIN_CONFIDENCE 0.75 Jaro-Winkler name-similarity floor (0-1) for a heuristic rename. Only the title is compared (the sidecar's [ti:] or its filename stem vs the audio's title or stem); the artist is excluded, because inside an album directory every candidate shares it and it discriminates between none of them. When neither side yields an artist/title the check is skipped (positional matching).
auto_apply_heuristic MXLRC_REALIGN_AUTO_APPLY_HEURISTIC false Whether serve-mode reactive realign may auto-apply heuristic (name-similarity) matches. Off = unattended realign applies only exact provenance matches; the manual CLI is unaffected.
name_match MXLRC_REALIGN_NAME_MATCH false Enable the opt-in N:M name-similarity matcher for directories with multiple orphaned sidecars and multiple sidecar-less audio files. Each orphan is scored against every remaining candidate and paired only when unambiguous (see min_margin); off by default, such directories are reported ambiguous.
min_margin MXLRC_REALIGN_MIN_MARGIN 0.05 Minimum score separation (0-1) an orphan's best-scoring candidate must hold over its runner-up before the pairing is accepted. Applies to both name-similarity tiers: for heuristic-nm the runner-up is the orphan's next-best candidate in the matrix; for the single-candidate heuristic tier it is the orphan's best score against any other audio file in the directory, including ones that already have a sidecar. Values outside [0,1) reset to the default.

The exact tier requires ISRC/MBID-tagged audio; libraries without those tags fall back to the heuristic tier (or, with name_match enabled, the N:M matcher for directories the single-candidate heuristic can't resolve).

[timing_validation]

[timing_validation]
enabled = false
revalidate_existing = false
revalidate_batch = 100
on_mis_synced = "demote"
on_categorical = "quarantine"

Governs serve mode's ongoing timing sweep, which re-judges synced .lrc files already on disk against the exact duration of the audio they sit beside. Four verdicts matter: ok, mis_synced (the cues run past the end of the audio, but the words are the right song's - the timing is wrong, not the content), categorical (the last cue sits far past the end, so the file holds a different song's lyrics entirely), and degenerate (every cue carries the same timestamp, so the file is not synced at all). A degenerate file has the right words and unusable timing, so on_mis_synced governs it as well. The revalidate command (dry-run by default, --apply to act) does the same work on demand and runs regardless of every key here; see Revalidate.

There is no threshold knob, deliberately. The overrun tolerance and the categorical ratio are a co-calibrated pair owned by internal/timing, and one predicate serves all four callers: the accept-time write guard, the worker's outcome stamp, the CLI, and this sweep. A sweep-local threshold would let an unattended pass demote a lyric the write path had already accepted, and would make recorded timing_outcome values incomparable across a single deployment's own history. What this section configures is the remediation - what happens to a file once the verdict is in.

The sweep is designed to converge: it judges a batch per cycle, records each verdict against the row, and then idles. A row carrying a verdict leaves the candidate query, so the backlog drains once rather than being re-read every cycle. Judging is ONE-WAY - a file judged once is left alone, and editing a .lrc by hand does not re-queue it; use the revalidate CLI to re-check a file that already carries a verdict.

Setting both action keys to off gives an observability-only mode: every verdict is still evaluated and recorded (visible on /metrics and in the Review queue report), and nothing on disk is touched. That is the recommended way to see what a sweep would do before letting it act.

Key Env Default Meaning
enabled MXLRC_TIMING_VALIDATION_ENABLED false Master switch for the serve-mode sweep. The revalidate CLI command runs regardless.
revalidate_existing MXLRC_TIMING_VALIDATION_REVALIDATE_EXISTING false Drain the backlog of .lrc files written before the accept-time guard existed. Both this and enabled must be true for the sweep to run.
revalidate_batch MXLRC_TIMING_VALIDATION_REVALIDATE_BATCH 100 How many sidecars one cycle judges. Modest on purpose: each candidate costs a read of the .lrc, and on a cold duration cache one duration probe of its companion audio to bank that duration - usually a header read, but a whole-file read for a VBR MP3 with no Xing header (~7% of MP3s measured), which is frame-counted end to end. That probe is paid once per file VERSION, never once per cycle, so the cost falls away as the cache fills; a small batch keeps a parked library array quiet meanwhile. Values below 1 reset to the default.
on_mis_synced MXLRC_TIMING_VALIDATION_ON_MIS_SYNCED demote Action for a MisSynced lyric. One of demote (write the plain words as .txt beside the audio, move the .lrc aside), quarantine (move it aside, keep nothing), purge (delete it - irreversible), off (record only).
on_categorical MXLRC_TIMING_VALIDATION_ON_CATEGORICAL quarantine Action for a categorical lyric. One of quarantine, purge (irreversible), off. No demote: the words belong to another song, so there is nothing worth keeping as .txt.

Quarantined files are moved under a quarantine root, preserving their path relative to their library root so two same-named sidecars from different albums cannot collide. purge unlinks the file outright; the JSONL backup record is then the only trail, which is why it is never a default.

ffmpeg resolution

Both verification and the instrumental detector need ffmpeg to extract an audio sample. You do not have to install or locate it yourself: when verification is enabled or a classifier URL is configured, the service resolves ffmpeg in this order:

  1. an explicit configured path (ffmpeg_path), if set - a missing configured binary is a hard error, never a silent download (air-gapped installs get a clear failure);
  2. a previously auto-provisioned binary in the cache;
  3. an ffmpeg already on your PATH;
  4. otherwise, a checksum-pinned static ffmpeg build is downloaded over HTTPS, verified against a pinned SHA256, and cached.

The auto-download is available for Linux (amd64/arm64) and Windows (amd64). On macOS there is no published static build, so install ffmpeg yourself (brew install ffmpeg) or set ffmpeg_path. The cached binary lives next to the database (the same data directory, or /config under Docker), under ffmpeg-<version>/.

Licensing: we do not bundle ffmpeg. The auto-downloaded build is a GPL static build from BtbN/FFmpeg-Builds, fetched at runtime only when needed. The official Docker images already include Alpine's ffmpeg package, so containers resolve it on PATH (step 3) and never download.

[guard]

[guard]
accepted_scripts = []
script_guard_threshold = 0.20

Optional language/script guard. It rejects fetched lyrics whose body is dominated by scripts outside the allowlist, so a wrong-language match (for example a Cyrillic or CJK body returned for a Latin-script track) is never written or cached. An empty accepted_scripts list (the default) disables the guard. Supported buckets: Latin, Han, Kana, Hangul, Other (env: MXLRC_GUARD_ACCEPTED_SCRIPTS, MXLRC_GUARD_THRESHOLD).

[queue]

[queue]
randomize = true
batch_size = 10

The worker shuffles its dequeue order within each priority tier so it stops querying the upstream API in strict alphabetical (library insertion) order, which is a plausible scraping fingerprint. This is on by default and affects only the library/serve worker path (Dequeue); inspection output (queue list) stays deterministic, and the one-shot fetch CLI never touches the work queue. Set randomize = false (or MXLRC_QUEUE_RANDOMIZE=false) to restore the deterministic created_at/id ordering. The env var overrides the TOML key; an invalid value warns and keeps the current setting.

batch_size controls a shuffled lookahead buffer: rather than reshuffling the whole eligible pool on every claim (ORDER BY RANDOM()), the worker draws batch_size rows at random once, stamps them with an order, and serves them in it before drawing again. This gives the queue a stable, knowable near-term order (so upcoming work can be displayed and starvation is observable) and lets the claim use an index instead of sorting the whole table each time. The randomness moves from per-item to per-batch, so an external observer still sees a randomly composed request stream and the anti-fingerprint property is preserved. Webhook-priority work still preempts within one dequeue. Default 10; set batch_size = 0 (or MXLRC_QUEUE_BATCH_SIZE=0) to disable batching and restore the per-item random path, a clean rollback. Only applies when randomize = true.

[watcher]

[watcher]
# enabled = false
# debounce_ms = 2000
# max_dirs = 100000

Optional low-latency filesystem watcher that layers targeted scans over the periodic scheduler (env: MXLRCGO_WATCH_ENABLED, MXLRCGO_WATCH_DEBOUNCE_MS, MXLRCGO_WATCH_MAX_DIRS). It is off by default. See Filesystem watcher in the User Guide for the operational detail.

Key Default Purpose
enabled false Master switch for the watcher.
debounce_ms 2000 Quiet period in milliseconds after the last filesystem event before a targeted scan fires; coalesces event storms. A non-positive value is clamped to the default at construction.
max_dirs 100000 Caps how many directories may be watched before startup fails - a safety valve so a misconfigured root fails fast instead of exhausting the kernel inotify watch budget. A non-positive value is clamped to the default.

[logging]

[logging]
level = "info"
format = "text"
# file = ""
# max_size_mb = 10
# max_files = 5
# max_age_days = 30
# compress = true

Log level, format, and the rotating file-log settings (env: MXLRC_LOG_LEVEL, MXLRC_LOG_FORMAT, MXLRC_LOG_FILE, MXLRC_LOG_MAX_SIZE_MB, MXLRC_LOG_MAX_FILES, MXLRC_LOG_MAX_AGE_DAYS, MXLRC_LOG_COMPRESS).