User Guide¶
This guide covers running canticle as a long-running service: the Lidarr webhook server, path resolution, health endpoints, Docker and Unraid deployment, the optional filesystem watcher, shell completion, and the inspection commands. For one-shot fetching and the full flag list, see the CLI Reference. For every setting, see Configuration.
Lidarr webhook server¶
MUSIXMATCH_TOKEN=YOUR_TOKEN MXLRC_WEBHOOK_API_KEY=mxlrc_your_webhook_key canticle --serve --listen 127.0.0.1:3876
MUSIXMATCH_TOKEN=YOUR_TOKEN MXLRC_WEBHOOK_API_KEY=mxlrc_your_webhook_key canticle serve --listen 127.0.0.1:3876
The server listens on MXLRC_SERVER_ADDR when --listen is not provided. Configure one or more webhook keys with MXLRC_WEBHOOK_API_KEY, use canticle keys create, or put the server address and webhook keys in a config file and start with canticle serve --config path/to/config.toml.
Webhook events are enqueued at high priority. If a webhook arrives for an artist/title that previously failed and is waiting out a retry backoff, the high-priority enqueue resets its retry timer so it becomes eligible immediately, jumping the queue. Scan-enqueued duplicates keep their existing backoff, so bulk scan traffic stays rate-limit protected. The worker's circuit breaker still pauses dequeuing globally when the upstream API signals rate limiting.
Lidarr rename hook¶
A lyric sidecar is orphaned when Lidarr renames a track file but leaves the
.lrc / .txt next to it under the old name. You can clean those up after the
fact with canticle realign, but you can also
prevent them entirely: contrib/lidarr-rename-sidecars.sh is a Lidarr Custom
Script that moves each sidecar alongside its audio at rename time.
Install it in Lidarr under Settings -> Connect -> Add -> Custom Script, point
it at the script path, and enable the On Rename trigger (the script exits
cleanly for every other event). It reads Lidarr's Lidarr_TrackFile_PreviousPaths
and Lidarr_TrackFile_Paths environment variables, and for each renamed track
moves the matching .lrc and .txt from the old stem to the new one - only when
the source exists and the destination does not, so it never clobbers an existing
file. Lidarr and the script must share the same view of the filesystem (identical
mount paths). See contrib/README.md for details.
Path resolution (Docker/Unraid)¶
Configured library scans are the source of truth for filesystem paths. When a Lidarr webhook arrives, canticle resolves the target file in this order:
- Scanned inventory. The webhook artist/title is matched against persisted scan results (using the same normalization as the cache), and a match reuses the exact container-visible source path and output destination the scan recorded. This is why you should add and scan your libraries (
canticle library add ..., thencanticle scan) before relying on webhooks. - Direct payload path. If there is no inventory match but the webhook payload carries a
trackFilespath that, after cleaning, lies inside one of your configured library roots and exists inside thecanticlecontainer, that path is used directly. Payload paths outside every configured root are never used as a write target; they fall back to metadata. This confinement is a security guard: it stops a webhook from directing a lyric write to an arbitrary location. As a result, raw payload-path resolution requires at least one configured library; with no libraries configured, step 2 is disabled and resolution goes straight from inventory to metadata. - Metadata fallback. Otherwise the lyrics are written to the configured
output.dirusing the webhook metadata.
On Unraid, Lidarr and canticle often see the same media through different mount paths. Because resolution prefers the scanned inventory, you do not need to maintain host-to-container path mappings: a payload path that is not visible inside the container, or that falls outside your configured library roots, simply falls back to metadata rather than failing.
Two operational notes:
- The library roots used to confine payload paths (step 2) are snapshotted when
servestarts. A library added withcanticle library add ...whileserveis running is not recognized for raw-payload-path resolution untilserveis restarted. (The periodic scheduler and watcher still pick up new libraries without a restart; only the webhook payload-path confinement uses the startup snapshot.) - Inventory matching for tracks with non-ASCII artist/title metadata converges after one rescan following an upgrade. The key-backfill migration applies a best-effort ASCII fold to pre-existing rows; the exact normalized keys are written on the next library scan, so run
canticle scanonce after upgrading to make non-ASCII webhook matches reliable.
The library scan runs on a wall-clock schedule, set under [server.scan_schedule]:
[server.scan_schedule]
frequency = "daily" # hourly | daily | weekly | off
at = "04:00" # 24-hour local clock, required for daily and weekly
scan_on_start = false
daily and weekly both require at, and weekly additionally requires
day (a weekday name, e.g. day = "sunday"). hourly and off need
neither. An incomplete combination is rejected when you save it rather than
at the next restart, so the server cannot be left holding a config it will
refuse to start on.
This anchors the scan to the clock, so it survives restarts. The older scan_interval_seconds measured its interval from process start instead, which meant that on a host restarting the container regularly (a nightly backup, say) the timer was reset before it could fire and every restart cost a full library walk. scan_on_start defaults to off for the same reason: the filesystem watcher already catches changes, so a restart need not re-walk everything.
A missed window is skipped rather than run late - if the machine was down through 04:00, the scan waits for the next day. See Configuration for the full reference including daylight-saving behavior.
scan_interval_seconds is deprecated but still honored whenever scan_schedule.frequency is blank, so an existing config keeps working; set the schedule when convenient. The worker poll interval is unchanged: set work_interval_seconds under [server] or MXLRC_WORK_INTERVAL, with precedence CLI flag (--work-interval) > environment variable > config file > default (it falls back to api.cooldown, clamped to a 15-second floor).
Health and status endpoints¶
serve exposes lightweight endpoints for container orchestration:
GET /healthz(unauthenticated) returns200with{"status":"ok"}whenever the HTTP server is accepting requests. Use it for Docker/Unraid liveness probes.GET /readyz(unauthenticated) verifies database reachability and returns200when ready or503when the database is unavailable. Error detail is omitted so it never leaks paths or connection strings. The check runs through a read-only connection, so a long write does not mark the daemon unready.GET /api/v1/status(requires anadmin-scoped API key) returns a queue summary grouped by status, for example{"status":"ok","queue":{"pending":3,"failed":1}}. It exposes no tokens, webhook keys, or filesystem paths.
Example Docker healthcheck: curl -fsS http://127.0.0.1:3876/readyz.
serve needs the database in WAL mode (a local filesystem that supports it) in every mode, with or without the web UI, and refuses to start otherwise.
Metrics endpoint¶
GET /metrics returns a Prometheus text-exposition (version 0.0.4) response suitable for scraping by Prometheus, Grafana Agent, or any compatible collector. Metrics are computed from read-only database queries at scrape time; there is no in-process registry or caching.
Exposed metric families:
| Metric | Type | Description |
|---|---|---|
mxlrcgo_queue_items{status="..."} |
gauge | Work queue item count per status (pending, processing, done, failed, deferred). done includes files a scan adopted, which Canticle never fetched. |
mxlrcgo_queue_failures{reason="..."} |
gauge | Failed queue items grouped by error reason. |
mxlrcgo_provider_hits_total{lane="..."} |
counter | Successful lyrics fetches per provider lane. |
mxlrcgo_provider_misses_total{lane="..."} |
counter | Benign no-result misses per provider lane. |
mxlrcgo_instrumental_tracks |
gauge | Queue items confirmed instrumental by audio detection. |
mxlrcgo_lane_state{lane="...",state="..."} |
gauge | Per-lane circuit state, one-hot over closed, open, half-open. lane="detector" appears in ordered mode when the detector is on. |
mxlrcgo_lane_open_until_timestamp_seconds{lane="..."} |
gauge | Unix seconds when the lane's open window ends; 0 unless the lane is open. |
mxlrcgo_lane_trips{lane="..."} |
gauge | Consecutive circuit trips for the lane; resets on success. |
Lane-attribution caveat (from v1.7.0). On a deployment whose [providers].primary was set to something other than musixmatch, the two mxlrcgo_provider_*_total families credited the wrong lane. The worker named its primary lane from a hardcoded constant rather than from the provider it had actually been given, so every hit and miss the primary provider earned was tallied under the lane="musixmatch" label. The window opens at v1.7.0, the release that introduced these counters (a non-musixmatch primary had been selectable since v1.4.0, but no counter existed to be skewed before v1.7.0), and closes with the release carrying the fix (v1.35.1 is the last release known to be affected). Deployments that left primary at its musixmatch default are unaffected.
These are cumulative counters in an append-only aggregate keyed on the lane name, with no per-row identity, so the wrongly credited increments cannot be located and reversed. The counters are not repaired retroactively: a scrape spanning the window carries the skew forward permanently, and a rate over a window that straddles the fix will show the primary lane appearing from nothing while musixmatch flattens. For any per-lane question about that period, use the Provider effectiveness report instead, which reads the per-track lane_attempts table. Note the two instruments count different things (see that report's own caveat below), so treat it as the better attribution rather than as a corrected version of these counters.
Access control. The endpoint is gated by the trusted-network allowlist, not an API key or session cookie. Loopback (127.x.x.x, ::1) is always trusted. A remote scraper (Prometheus server, Grafana Agent, etc.) must have its host CIDR listed in [server.trusted_networks].cidrs. A spoofed X-Forwarded-For header cannot forge a trusted source.
To enable a remote scraper, add its CIDR to config.toml:
Or override with the environment variable: MXLRC_TRUSTED_CIDRS=192.168.1.0/24.
Example Prometheus scrape_configs entry:
Docker¶
The container runs the webhook service on port 50705 and stores its config and SQLite database under /config. Mount your media following the TRaSH Guides single-mount convention: map your data parent to /data and add your music root with canticle library add /data/media/music. In serve mode lyrics are written next to each audio file, so the container does not need an output directory: MXLRC_OUTPUT_DIR is ignored for normal serve output (it only sets the fallback location for the metadata-only webhook path, where no source file is known). Just mount your media and register the library; there is nothing else to point at an output folder.
Published GHCR tags:
latest- latest stablev*.*.*release<version>- exact release version, for example1.0.0<major>.<minor>- stable minor line, for example1.0beta- latest prerelease channel tag<version>-<pre>- exact prerelease version, for example1.1.0-beta.1or1.1.0-rc.1dev/nightly- latest scheduled build frommainnightly-YYYYMMDD- dated nightly build frommain
docker run -d \
--name canticle \
-p 50705:50705 \
-e MUSIXMATCH_TOKEN=YOUR_TOKEN \
-e MXLRC_WEBHOOK_API_KEY=mxlrc_your_webhook_key \
-e PUID=99 \
-e PGID=100 \
-v canticle-config:/config \
-v /path/to/your/data:/data:rw \
--restart unless-stopped \
ghcr.io/sydlexius/canticle:latest
For Compose, copy docker-compose.example.yml, set MUSIXMATCH_TOKEN and MXLRC_WEBHOOK_API_KEY, adjust the music volume, then run:
MXLRC_DOCKER=true makes default storage paths resolve to /config/config.toml and /config/mxlrcgo.db.
To inspect or maintain the queue and scan state inside the container, exec the same canticle queue and canticle scan results / canticle scan clear commands documented in the Inspection commands section below (for example docker exec canticle canticle queue failed).
Encrypted secrets¶
The two recoverable runtime secrets, the Musixmatch API token and the serve-mode webhook API key, can be stored encrypted at rest in the SQLite database (AES-256-GCM) instead of as plaintext in config.toml and environment variables. This is opt-in: existing env/TOML setups keep working unchanged, because the encrypted database store is the lowest-precedence source. CLI flags, environment variables, and TOML all still win over it.
What it protects (and what it does not)¶
At-rest encryption defends a narrow boundary. It protects against database-only exfiltration: a copied or backed-up .db file, an accidental git commit, or a stray copy of just the database cannot be read back into the plaintext token. It does NOT protect a compromised running process (the decrypted values are in memory at use time), and it does NOT protect whole-volume theft when the key file lives in the same mount as the database. For whole-volume-theft protection, use MXLRC_MASTER_KEY or a separately-mounted key file (see below).
Managing secrets¶
Three commands manage the encrypted store. None of them ever print a secret value.
# Encrypt the currently effective plaintext secret(s) into the database. Reads
# the normal CLI/env/TOML precedence but skips the database itself as a source,
# so it never copies the database onto itself. With no flag it imports both
# currently-set secrets; scope it with --token or --webhook.
canticle secrets import
canticle secrets import --token
canticle secrets import --webhook
# Set one secret by name from stdin. The value is read from the terminal prompt
# or a pipe, never from the command line (the command line lands in shell
# history and ps). Valid names: musixmatch_token, webhook_api_key.
canticle secrets set musixmatch_token # prompts for the value
printf '%s' "$TOKEN" | canticle secrets set musixmatch_token # or pipe it
# List stored secret names and their last-updated timestamps. Never prints values.
canticle secrets list
secrets import is idempotent: re-running overwrites the existing encrypted row. After a successful import, the command reminds you to remove the now-redundant plaintext from config.toml and your compose environment so it is no longer stored in the clear.
Key management¶
The 32-byte encryption key is resolved from one of two sources, in order:
MXLRC_MASTER_KEY(base64 of 32 random bytes). When set, it takes precedence and no key file is read or written. Use this to keep the key entirely off the data volume (recommended hardening when your threat model includes whole-volume theft). Generate one withopenssl rand -base64 32.- A key file (32 raw bytes, auto-generated with
0600permissions on first use). The default location is a hidden.mxlrcgo.keybeside the database. This is the universal zero-setup default on all platforms, including Docker. Override the location with thesecrets.key_fileTOML key or theMXLRC_SECRETS_KEY_FILEenvironment variable to place the key file on a separate, narrower mount.
A malformed MXLRC_MASTER_KEY or an unreadable key file is a loud, fatal startup error. The daemon never silently falls back to running unencrypted.
Docker key setup¶
In Docker mode (storage paths resolve under /config), the daemon auto-creates a 0600 key file at /config/.mxlrcgo.key on first use - the same zero-setup behavior as native installs. No extra step is required to start.
Important caveat: /config/.mxlrcgo.key lives in the same bind-mount as the database. An attacker who copies the entire /config volume gets both the key and the ciphertext, so the at-rest encryption only protects against database-only exfiltration (backups, stray copies), not whole-volume theft. If that is in your threat model, set MXLRC_MASTER_KEY to keep the key off the data volume entirely:
# docker-compose.yml - optional hardening: key separated from the data volume
services:
canticle:
image: ghcr.io/sydlexius/canticle:latest
environment:
# Optional. Generate once: openssl rand -base64 32
# Source from a .env file NOT inside the /config volume.
MXLRC_MASTER_KEY: ${MXLRC_MASTER_KEY}
volumes:
- ./config:/config
Alternatively, point MXLRC_SECRETS_KEY_FILE at a separately-mounted file to get key/data separation without an environment variable:
Precedence¶
The encrypted database store is the lowest-precedence source for both secrets:
- Musixmatch token:
--tokenCLI flag, thenMUSIXMATCH_TOKEN, thenMXLRC_API_TOKEN, then TOMLapi.token, then the encrypted database store. - Webhook key: the CLI/env flag, then TOML
server.webhook_api_keys, then the encrypted database store.
A higher-precedence source is used as-is at runtime and is never auto-persisted to the database. Persistence happens only when you run secrets import or secrets set.
Key-loss recovery¶
Losing the key - a deleted .mxlrcgo.key file or a lost MXLRC_MASTER_KEY value - makes the encrypted secrets unrecoverable by design: there is no backdoor and no recovery key. The remedy is to re-enter the secrets with their original plaintext, by re-running secrets import (from a config or environment that still has the plaintext) or secrets set. Back up your key file or MXLRC_MASTER_KEY value in a safe location outside the data volume.
Unraid¶
Canticle is available in Unraid Community Applications. The template is maintained in the sydlexius/unraid-templates repository, which is the repository Community Applications indexes; it is not kept in this repository. The template provides a GHCR image, bridge networking, /config appdata, a music library mapping, and advanced PUID/PGID permission and tuning fields (scan/work intervals and the filesystem watcher).
Library mounts. Prefer mapping the parent of your media into the container once and adding library roots beneath it, rather than a separate mount per library. This keeps container-visible paths stable and matches the single-mount convention used by the TRaSH Guides Unraid layout, which maps /mnt/user/data to /data with media under /data/media:
| Host path | Container path |
|---|---|
/mnt/user/data |
/data |
Then register the library (or libraries) under it (paths are container-visible):
docker exec canticle canticle library add /data/media/music --name Music
docker exec canticle canticle scan
(Unlike the *arr apps, Canticle never moves or hardlinks files; it only reads audio and writes a .lrc/.txt sibling. The single-mount convention is still worth following so paths match the rest of your stack.)
If your music instead lives in several separate top-level shares, map their common parent once, or add one Path mapping per share beneath /data/media (for example /mnt/user/<share> to /data/media/<share>) and register each with library add. Lyrics are written next to each audio file, so libraries do not need a shared output root; set MXLRC_OUTPUT_DIR only for the webhook metadata-fallback case (step 3 under Path resolution).
Web UI enablement¶
The browser UI is off by default. The serve listener only mounts the web routes when the UI is explicitly enabled, so a fresh Docker or Unraid deployment serves the webhook API alone until you turn it on.
Enable it (headless). Set MXLRC_WEB_UI_ENABLED=true in the container environment (the Unraid template ships this variable, and docker-compose.example.yml sets it). The equivalent config-file setting is web_ui_enabled = true under [server] in config.toml; the env var overrides the file when both are present. With the UI off, neither the pages nor the /login or /setup routes exist.
Bootstrap the first admin (headless). The setup page (/setup) is reachable to any client that can reach the port until the first admin exists, so a remote Unraid box can complete first-run setup through the browser without granting a trusted-network bypass. Env-bootstrap remains the better choice on an exposed network: it creates the admin before the listener ever serves /setup, closing the window entirely rather than leaving it open until someone visits the page.
Secret hygiene. Never hardcode the bootstrap password in a compose file or source control. Supply it from an uncommitted .env file or a secret manager using ${MXLRC_WEBAUTH_ADMIN_PASSWORD} interpolation, as shown in docker-compose.example.yml. The binary reads MXLRC_WEBAUTH_ADMIN_PASSWORD from the environment variable only - it does not read Docker or Swarm secret files (/run/secrets/...) directly. If you store the password as a Docker Secret, export it into the variable in your container entrypoint before the service starts: export MXLRC_WEBAUTH_ADMIN_PASSWORD="$(cat /run/secrets/webauth_admin_password)".
Bootstrap behavior:
- The web UI must be enabled. The bootstrap only runs when
web_ui_enabled = trueunder[server](orMXLRC_WEB_UI_ENABLED=true). With the UI off the variables are ignored and no admin is created; since v1.20.1 that is logged at warning level naming the setting to change. - Both vars required. Setting only one logs a warning and skips the bootstrap.
- Password floor. The password must be at least 8 characters. A shorter one is a fatal startup error (the container will not start), not a silent skip.
- Idempotent. The bootstrap runs only when no admin exists yet. Once any admin account is present it is skipped (never an overwrite), so leaving the vars set across restarts is harmless. The password is never logged.
- It is a bootstrap, not a rotation control. Changing
MXLRC_WEBAUTH_ADMIN_PASSWORDafter an admin exists and restarting does not change the password. The bootstrap is skipped, the service starts healthy, and the previous password stays active. Since v1.20.1 that skip is logged at warning level and names the command below; older builds logged it at info level, where it read as reassurance. To change an existing password, use Changing or resetting the admin password.
First login and cleanup. After the container is up, browse to http://[host]:[port]/login and sign in with the bootstrapped credentials. Then rotate the password with canticle admin set-password (see below) and remove the MXLRC_WEBAUTH_ADMIN_USER / MXLRC_WEBAUTH_ADMIN_PASSWORD env vars. Because the bootstrap is idempotent, the stale vars do nothing on the next start, but removing them keeps the plaintext password out of the container environment.
Changing or resetting the admin password¶
Use canticle admin set-password. This is the only supported way to change an existing admin password: there is no password-change screen in the web UI yet (tracked in issue #545), and editing the bootstrap environment variable does nothing once an admin exists.
In Docker or Unraid, run it inside the running container, piping the password in from the host so it never appears in the docker exec arguments:
Do not embed the password in the command itself (for example inside sh -c '... printf "your-password" ...'). Anything on the command line is visible in the host's process list to every other user on the machine, and lands in your shell history, which are exactly the channels reading from stdin avoids.
Notes on how it behaves:
- The password is read from standard input, never a flag. A
--passwordflag would place the credential in your shell history and in the host's process list, where any other user on the machine can read it. Read it from a file or a secret manager. Typing it inline (printf '%s' 'pw' | ...) keeps it off the process list but still records it in shell history, so treat that as a last resort and clear the entry afterwards. This mirrorscanticle secrets set, which rejects a value passed on the command line for the same reason. - A trailing newline is stripped, so the usual
echoand heredoc forms behave as expected. Leading and trailing spaces are preserved, because they may be part of the passphrase. - Existing sessions are revoked. Anyone signed in with the old password is signed out immediately. That is the point of rotating a compromised credential, and it means you will need to sign in again yourself.
- The change is atomic. The password update and the session revocation happen in one database transaction, so a rotation either fully applies or leaves the account untouched. It cannot half-apply.
- No restart is needed. The change takes effect immediately.
- The password is never printed, and the minimum length is 8 characters.
If you are locked out entirely (password lost, and no session), the same command works because it acts directly on the database rather than requiring a login. Run it on the host or inside the container as shown above. You do not need to delete anything from the database, and you should not: on releases before v1.20.1 the only route was removing the admin row by hand, which is no longer necessary and is easy to get wrong.
If your build predates v1.20.1, canticle admin set-password is either absent or unreachable (in v1.20.0 the subcommand shipped but could not be invoked, falling through to the legacy argument parser with unknown argument --user). Upgrade to v1.20.1 or later, then use the command above.
If the UI is reachable beyond your local machine, also read Security considerations below: put it behind TLS so the session cookie is not sent in cleartext.
Web UI: Dashboard¶
The Dashboard (/dashboard) is the default landing page after you sign in - the bare root (/) redirects to it. It is a never-cached observability view that runs live database queries at request time; apart from the optional mark icons described in Web UI: Marking a track, it changes nothing. It shows:

- Work queue tiles and chart. Counts of work queue items by status (Retrying, Errored, Queued, Finished, Settled (upgradable), Given up), with a doughnut chart of the same segments. Retrying is a track with no lyrics found yet that the worker will look at again; Errored hit an error and is also retried automatically after a backoff; Queued is waiting for its first lookup; Given up is a track retired after every lyrics source repeatedly found nothing (revived with
canticle queue recheck --retired). A track the worker is fetching right now has no tile: it appears at the top of the Up Next panel, so the tiles add up to the total minus the in-flight rows. Completed tracks are split in two, because word-synced output is the terminal state whenever a word-capable lane can run: Finished counts completed tracks (including files a scan adopted) whose lyrics carry word-level timing on disk (the same rows as the word-synced tile below), and Settled (upgradable) counts every other completed track - line-synced, unsynced, instrumental (detected or provider-flagged; hand-marked ones count as Finished), or a synced file whose tier is not yet recorded. Each is the track's current best result, not treated as finished (word-synced is the only finished state while a word-capable lane can run); being counted here does not mean an upgrade will be attempted automatically (the serve-mode upgrade sweep that re-fetches settled tracks is off by default). The chart shows Finished and Settled (upgradable) as separate segments. A track with no recorded tier counts as upgradable untilcanticle scan reconcile-sync-tierclassifies it, so Finished never overstates. Withoutput.word_sync_mode = off, with no word-capable lane configured (Musixmatch with an API token, or PetitLyrics, as primary or fallback, and not disabled), or with lyrics fetching disabled because a Musixmatch primary has no token (serve then fetches nothing, whatever fallbacks are configured), no track can reach word timing, so line-synced is the best result: Finished then also counts completed line-synced tracks, Settled (upgradable) holds only what is below line sync, and the tooltips and bucket pages say so. The split is decided when the page loads, so changing the mode or the enabled lanes (which takes effect on restart; it follows configuration, never a lane's health) re-buckets every track with no migration. The Work Queue row shows statuses only; what a completed track ended up with is the Results row below. - Results. The completed tracks (Finished plus Settled (upgradable)) split by result type, in six tiles that always add up to that pair, so every completed track is in exactly one (files a scan adopted are included, classified from the lyric file beside the audio): Word-synced (word-level timing landed on disk - the terminal tier when a word-capable lane can run, nothing further to gain), Line-synced (line-level timing only, still eligible for a future word-sync upgrade; the best result when no word-synced tier is available, see the Finished tile above), Unsynced (plain-text lyrics only), Instrumental (marked instrumental: by audio detection, a provider's own flag, or by hand), Tier unknown (a synced
.lrcwhose tier is not recorded), and Other (completed with nothing written: refused by the language guard, retired because the audio file is gone, or settled before outcomes were recorded). Results counts completed tracks only; a row undergoing a word-sync recheck is not completed, so it appears in the Work Queue's Retrying count and in no Results tile. The tier describes the file on disk, recorded when a track completes, so a new completion is tiered whichever lane or cache served it and whateverword_sync_modeis set to. Tier unknown is mostly tracks completed before tiers were recorded; it also covers a row the timing guard later quarantined or demoted.canticle scan reconcile-sync-tierclassifies the existing unrecorded files from disk, without any provider requests. These are never merged into one "synced" number, so you can tell how much of the library is actually finished versus still improvable. - Lyrics Sources tiles. One tile per lyrics source. The big number is how many finished results came from that source (completed tracks by the lane that served them), so the row adds up to Finished + Settled (upgradable). Beneath it, the hit rate is that source's winning lookups divided by all its recorded lookups (for example "Hit rate 71% (10 of 14 lookups)"), with a small bar; a lookup that found lyrics but lost to a later source stays in the total as a miss, so the rate is not the big number. A source with finished results but no source name recorded shows as Unnamed source with no link. An Unattributed tile counts completed tracks with no recorded source (including files a scan adopted, which Canticle never fetched); click it to open the list of those tracks, and the Queue page's Source filter offers Unattributed for the same set.
- Recent outcomes. The 20 most recently completed tracks (artist, album, title, result class, the provider lane that served it, and the completion time in the server's timezone when
TZis set, otherwise UTC). Files a scan adopted appear here too, with no provider lane and the adoption time as the completion time. A synced result carries a colored tier badge: word-synced (green) or line-synced (blue) reads as such in the badge text; a tier-unknown synced result gets a muted grey badge reading "synced (tier unknown)", so the tier is stated in text, not by color alone. Each row has an Actions column with the mark icons. - Needs attention. Failed and deferred tracks that have no lyric outcome yet, failed ones first, with the reason and the last attempt. Its Actions column offers Mark instrumental (see Marking a track).
- Up Next. The tracks the worker has lined up to take next (a short lookahead of the eligible ones), with any track being processed right now at the top. Its Actions column offers Mark instrumental, except on the track being processed.
The Dashboard requires the web UI to be enabled and an admin session (or a trusted-network request), the same as the other UI pages. For the deeper per-report views (recent outcomes, provider effectiveness, instrumental inventory, failure analysis, deferred misses), see the Reports workspace below.
Searching a queue bucket. Each queue bucket page (/queue/{bucket}) has a search box above the table. Type part of an artist or title and press Search: the list narrows to the tracks in that bucket whose artist or title contains the text, ignoring case and most accents (searching bjork finds an accented name). The search is a plain substring match, so characters such as % and _ match themselves. The text is kept in the page address (?q=), so a reload or a bookmark keeps the search and Show more continues through the matches only. Search text is limited to 200 characters. Clear search returns to the whole bucket. The search text is never written to the server log (the request log keeps only the page path and a short allowlist of non-text parameters).
Web UI: Lyric preview and offset editor¶
A queue bucket's page (for example Settled) has a Preview link on rows whose lyrics file is synced. It opens a player (the same page also opens by its address, or after a mark action, for a track with no synced lyric whose audio is in a library): the track's audio on top, the lyrics below, with the current line highlighted as the audio plays.
Finding a track to edit¶
- Click Queue in the sidebar. The page lists each bucket with its track count; the counts are the same as the Dashboard's queue tiles.
- Open Finished (word-synced tracks) or Settled (upgradable) (line-synced and the rest). With
word_sync_mode = offor no word-capable lane configured, line-synced tracks are in Finished instead. These two hold the completed tracks whose rows offer the player. - Click Preview on a row whose lyrics file is synced. The player has a link at the top that returns you to the bucket you came from. Line-synced tracks get the timing editor below; a word-synced track opens read-only. The Line-synced (editable) chip on the bucket that holds them lists only the editable ones.
For a line-synced track the player also shows a Timing offset panel. Use it when a provider's lyrics are right but consistently early or late against your copy of the recording (a different release or edit than the one the lyrics were timed to).
Editing the offset¶
- Play the track. Drag the slider for a rough fit (-5 s to +5 s), use the nudge buttons (-1s, -0.1, -0.01, +0.01, +0.1, +1s), or type any value in seconds into the offset field and press Enter. A positive offset makes the lyrics later, a negative one earlier.
- The status chip reads Original (never edited), Unsaved (you have a change in the page that is not written yet), Saving, or Edited (saved). Nothing touches the file until you save.
- Press Save and confirm. Discard drops the unsaved change. Revert to original appears once a saved edit exists and no change is pending.
The offset you see is always measured from the original timing, never from the last save, so saving twice does not compound. A line pushed before the start of the track is held at 0:00.
Keyboard shortcuts work while the panel is on screen:
| Keys | Action |
|---|---|
| Space | Play or pause |
[ and ] |
Earlier or later by 0.1 s |
Shift + [ and ] |
Earlier or later by 1 s |
Alt (Option on a Mac) + [ and ] |
Earlier or later by 0.01 s |
| Cmd+S (Mac) or Ctrl+S (others) | Save |
| Esc | Discard the unsaved change |
While you are typing in the offset field only Esc and the save shortcut are active, so the other keys go into the field. Space or Enter on a focused button presses that button.
The confirmation dialog has a "Don't ask again in this browser" box. The choice is remembered by that browser only, not by the server or other browsers.
Finding the offset by ear¶
If you do not know how far off the lyrics are, let your ears find it. Press Find by ear (labeled By ear on a phone, or the E key) to turn the mode on, then click a lyric line: only that line's stretch of the track plays, at the current timing. Listen for which line is actually sung, then click that line. The difference between the line you played and the line you heard becomes the offset. Click the same line again if what you heard matches it, which means its timing is already right. Replay repeats the snippet, Cancel test drops the pending test and keeps the offset, and Done leaves the mode. The result is an unsaved change like any other: check it with Play, fine-tune with the nudges, then Save.
When the audio will not play¶
The player streams your original audio file as it is, so unless the optional FLAC fallback below is on, it can only play what your browser can decode. MP3, AAC, WAV and FLAC play in every current desktop and phone browser. Safari plays Ogg Vorbis and Opus from Safari 18.4, so an older Safari may not play an Ogg file. An .m4a plays everywhere when it holds AAC; an Apple Lossless (ALAC) .m4a plays in Safari and in most iPhone and iPad browsers (those that use Safari's engine), but not in Chrome, Firefox or Edge. WebM audio with Opus plays in Chrome, Firefox and Edge. WMA and APE do not play in Chrome, Firefox or Edge.
When the browser refuses the stream, the player says so in red above the controls, naming the file's format (for example "M4A file, served as audio/mp4"), and the Timing offset panel's status line repeats that the audio cannot be played. The message means this browser cannot decode the file (or the file could not be read), not that the lyrics are wrong. The lyrics are still shown and the timing editor still works: you can type an offset or use the nudges and Save, but Find by ear is turned off because it needs playback. To check an offset by ear, open the page in a browser that can play the format, or play the file in a desktop player. A "network error" message instead means the stream was cut off; reload the page to try again.
Optional FLAC fallback. Set preview_flac_fallback = true under [server] (env MXLRC_PREVIEW_FLAC_FALLBACK=true, restart to apply; off by default) and a track the browser cannot decode, such as ALAC or WMA, is retried once as a FLAC conversion made by ffmpeg, so it plays and seeks. The only trigger is the browser's own decode error: nothing is converted for a track that plays natively or for a network error, and the conversion happens when you open the player, taking a few seconds for a long track. FLAC is lossless, so timing is unchanged. Conversions are kept in a temporary cache (up to 512 MiB, oldest removed first, removed when Canticle stops) and are never written to your library. If the conversion cannot play either, the red message above appears as usual. This does not widen the list of formats ffmpeg can read: a format ffmpeg cannot decode fails the same way.
What a save does¶
- The first successful save copies the current file to a
.lrc.origfile beside it (for exampletrack.lrc.orig), then writes the shifted file. The backup is created once and never overwritten. If a.lrc.origalready exists (for instance from an earlier backfill), it is used as the original and left alone. - Later saves are applied to that original, not to the already-shifted file.
- Revert to original writes the original timing back and keeps the
.lrc.orig. The reverted file is in Canticle's normal form (one line per timestamp), which can differ in layout from a hand-formatted original. Reverting a track that was never edited is refused. - Header lines such as
[ar:]or[offset:]are carried over unchanged. The editor never writes or changes an[offset:]header; it shifts each line's own timestamp. If the file already carries a nonzero[offset:], a player that honors that header applies it on top of the shifted timestamps, so playback can differ from a player that ignores it.
When a save is refused¶
Nothing is written, and the panel says why, when:
- the lyrics file changed on disk since the page loaded (reload the page and edit the current file);
- the shifted timing fails the timing check against the audio's exact length (an unknown length passes, as everywhere else);
- the track is being processed right now (try again shortly).
The panel only appears for a finished, line-synced file. Any other row shows the player without it, and a row that stops being editable while you are on the page (for example because it was picked up for processing) is refused with a generic error; reload the page to see its current state.
Lines that would start more than 2 seconds after the track ends are marked, and Save stays off until they fit.
A file with word-level timing (a Canticle-written .elrc companion, recognized by its [by:canticle] header, or word marks inside the .lrc) is read-only here, and the panel gives the reason: shifting only line starts would put the words out of step. An .elrc written by another tool is ignored and does not block editing.
Edited tracks are protected¶
A track you saved an edit on is not replaced by the automatic upgrade and word-sync recheck passes, so a hand-tuned file is not overwritten by a later fetch; moving an edited track to another folder re-attaches it to its new location without re-fetching it (move the .lrc with the audio). The timing-validation sweep still records its verdict for an edited track but never demotes, quarantines or purges the file, and identity repair corrects the track's details without reopening it for a new fetch. Explicit operator commands still can: --update, a manual re-fetch, and canticle revalidate --apply act on the file regardless. Use Revert to original first if you want the automatic passes to consider the track again.
Marking a track from the player¶
The player has a separate Track marks card, apart from Save and Discard. On a track with a timing editor it sits under the timing panel (on a phone, under the lyrics and the timing bar). On a word-synced track, or one with no lyric to show, it sits under the audio player and any notice, above the lyric list if there is one, at any width. It offers the same two actions as the list screens, on the same conditions, with the same confirm page and the same undo; confirming returns you to the player, and the one-line result is the first line inside the Track marks card.
- Mark instrumental is offered on every track that is not being processed. A track you marked shows a notice and Undo instrumental instead.
- Lyrics are wrong is offered whenever the track has a lyric written, synced or an unsynced
.txt, including a word-synced track (which still has no timing editor). A track whose lyric is not a readable synced.lrcshows a notice that there is no synced lyric to play along with; the audio still plays. A track with blocked lyrics shows a notice and Unblock lyrics instead.
After either action the lyric list is gone, because the file was removed, and the card says what the track is now. The player still opens for a track with no synced lyric (an instrumental mark, blocked lyrics, or a plain-text lyric), as long as its audio is in a library, so you can undo from the same page. These buttons have no keyboard shortcuts, so they never collide with the editor's. See Marking a track for what each action does and backs up.
Web UI: Marking a track¶
Two actions let you overrule Canticle on a single track from the web UI. Mark instrumental is for a track with no vocals that Canticle has given lyrics, or keeps looking for lyrics for. Lyrics are wrong is for a track whose fetched lyrics are another song's words. Each has a confirm page that says what will happen before anything changes, and each has a way back: undo the instrumental mark, or unblock the lyrics. Mark requests must also come from the same origin as the page. They do the same work as the command line: see Mark a track instrumental by hand and Mark a track's lyrics as wrong for the CLI versions, which behave the same way.
Where the icons are¶
Every table of tracks that offers these actions has a last column headed Actions:
- the track list on each Queue bucket page (
/queue/{bucket}), - Recent outcomes, on the Dashboard and in Reports,
- Needs attention, on the Dashboard and in Reports,
- Up Next, on the Dashboard.
The column holds up to two small icon buttons on one line, so a row is never taller because of them. On a Queue bucket page the same cell also holds the Preview link and the Edited badge, where those apply.
- Microphone with a slash: Mark instrumental. It is on every track that is not being processed right now.
- Flag: Lyrics are wrong. The Lyrics are wrong (off) flag is only on a track whose record says a synced or unsynced lyric was written, and that is not failed, given up or being processed. It is never on a track you marked instrumental, or anywhere in Needs attention or Up Next, because those tracks have no lyric yet. A track that still has blocked lyrics shows the on flag whatever its state (except in Needs attention and Up Next, and while it is being processed).
- Nothing on a track being processed right now (it is listed at the top of Up Next and on the Processing queue page). Try again when it finishes.
Hover an icon to see its full name; screen readers read the same words. An icon shown on a light rounded square is on, and tells you the track's state:
- a microphone on that square means the track is marked instrumental by hand, and it leads to the undo page;
- a flag on that square means the track has blocked lyrics, and it leads to the unblock page. A blocked track keeps its flag on even when no lyric is left on disk, so you can always find the way back.
On a phone the tables scroll sideways inside their own box (the page itself does not), so swipe the table to bring the Actions column into view.
What each action does¶
Each icon opens a confirm page that names the track, says what will happen, and, for Mark instrumental and Lyrics are wrong, shows the number of lyric files it will remove. Cancel returns to the page you came from and changes nothing. Confirming does the work and returns you to the same page with a one-line status at the top.
- Mark instrumental. Each lyric file for the track (
.lrc,.txt, and a word-timing companion Canticle wrote) is saved to a backup file and removed, and an instrumental marker is written beside the audio in its place. The track is settled and protected: a scan, an upgrade or a re-fetch will not replace the marker, and it is not looked up again until you undo this. A saved timing edit is dropped with the lyric. The track leaves Up Next, Needs attention and the other queue lists, and is listed in Recent outcomes and under Finished on the Queue page (a hand-marked track never counts as Settled (upgradable)). A track with no lyric file at all can be marked too; nothing is backed up then. - Undo the instrumental mark. The marker file and any real lyric file beside the audio are saved to a backup file; the marker is removed and a real lyric file is left in place. The track is queued again with its attempts and last error cleared (its count of misses is kept), so a track that was failing is retried rather than put back in its failed state. It shows under Retrying on the Queue page (or back in Up Next if it was there) until the worker takes it. The lyric file that was replaced when you marked the track is not brought back; it is in the mark's backup, and the next fetch decides what the track gets.
- Lyrics are wrong. The track's lyric files are saved to a backup file and removed, those exact words are remembered as blocked so they are never accepted for this track again from any source, and a completed track (or one waiting for a word-timing recheck) is queued again so another result can be fetched; a track already queued keeps its place. A saved timing edit is dropped with the file. The track leaves Recent outcomes and shows under Queued on the Queue page with its flag on. If every result found later is also blocked, the track settles with nothing on disk and is listed under Settled (upgradable) (and by the Blocked tile in the Dashboard's Results row) until you unblock it.
- Unblock. The blocks on the track are removed, so those words can be written again. No lyric file is restored, and nothing is backed up because nothing on disk is removed. A track that was left waiting because everything found was blocked is queued again. A track that has since been given a different lyric keeps it and is not queued.
Marking is refused, with a "Not done" line and no change, when the track no longer exists, is being processed right now (try again in a moment), has no lyric file on disk (Lyrics are wrong only), is marked instrumental (Lyrics are wrong only: undo the mark first), or failed or was given up on (Lyrics are wrong only: revive it first). If the track is already in the state you asked for, the line says nothing changed. A failure says to see the server log; part of the work may already be done (the backup is written first), and running the action again finishes it.
What is backed up, and how to restore¶
Before a file is removed, its bytes are appended to a JSONL backup record in the folder that holds the database, using the same names as the CLI, with the time in UTC:
| Action | Backup file | What it holds |
|---|---|---|
| Mark instrumental | instrumental-mark-backup-<timestamp>.jsonl |
the lyric files that were replaced |
| Undo the instrumental mark | instrumental-unmark-backup-<timestamp>.jsonl |
the marker files that were removed, and any real lyric file beside the audio (left in place) |
| Lyrics are wrong | mark-wrong-backup-<timestamp>.jsonl |
the lyric files that were removed |
| Unblock | none | nothing is removed from disk |
One click makes one file, and none is made when there is no file to save. For Mark instrumental and Lyrics are wrong, the status line reports how many files were saved, when at least one was. To put a file back by hand, use the steps in the two CLI sections linked above; the records are the same ones, and the record to restore after marking is the mark record (mark-wrong for Lyrics are wrong).
When the icons are missing¶
The icons are drawn, and the mark pages answer, only when the server knows where to write backups. canticle serve always supplies that (the folder of the database file, and a database path is required to start), so in a normal deployment this does not come up. If the icons are ever absent and the mark pages answer 404, the server was started without a database location for the backups; nothing is wrong with your tracks, and no mark can be made until that is fixed. Opening a confirm page for a track number that does not exist also answers 404; confirming for a track that has since been removed returns to the page with a "Not done" line.
Like the other write actions, these need your signed-in session (or a trusted-network request), and every confirm form carries a CSRF token.
Web UI: Settings page¶
The Settings page (/settings) is the single destination for editing the daemon configuration from the browser. It is available whenever web_ui_enabled = true and the operator is signed in as admin (or is accessing from a trusted network).
Prerequisite. The page is only reachable when the web UI is enabled (web_ui_enabled = true under [server] or MXLRC_WEB_UI_ENABLED=true). See Web UI enablement for the bootstrap steps.
Tab layout¶
The page has three CSS-only tabs (no JavaScript round-trip to switch):
- Common - everyday fields: API token, webhook key, listen address, web UI toggle, and similar high-touch settings. This is the default tab.
- Advanced - every other field, organized by config section (API, output, providers, verification, instrumental detector, enrichment, guard, queue, logging, and so on).
- Raw config - a read-only view of the effective configuration as TOML, with secrets redacted. Use this to verify exactly what the daemon is running before a restart.
Risk-tiered saves¶
Fields are classified into three risk tiers that control how a save is triggered:
- safe - auto-saves on change; no button click required.
- caution - requires an explicit Save button click.
- critical - requires Save plus a browser confirmation dialog before the value is written.
Changes are written to the config file and take effect on the next restart, not live. A status line below each field reports the result of the save POST.
Locked fields¶
A field whose value is set by an environment variable or a command-line flag shows a "Locked" pill and cannot be edited in the UI. The override source is described below the field. To regain control from the UI, clear the environment variable (or remove the flag) and restart the daemon.
CSRF¶
Write operations use a double-submit cookie token (mx-csrf-token). This is transparent during normal browser use but means direct curl POSTs without the matching cookie are rejected. The page must be loaded from the same origin before any write can succeed.
Recommended workflow¶
- Open the Common tab for routine changes (rotating the API token, updating the webhook key, changing the listen address).
- Use the Advanced tab for tuning (backoff windows, provider mode, verification thresholds, etc.).
- After saving, open the Raw config tab to verify the effective state before restarting the daemon.
- Restart the daemon to apply changes.
Webhook API keys¶
Lidarr (and any other webhook caller) authenticates to the serve listener with a webhook API key. Keys can be managed two ways: from the web UI or from the canticle keys CLI. Both write to the same key store in the database, so a key created on the CLI works for the UI list and vice versa.
A key's raw value is shown exactly once at creation and is unrecoverable afterward (only a hash is stored). Copy it then; if you lose it, revoke it and create a new one. Keys carry one or more scopes: webhook (accept Lidarr webhook calls - the common case) and admin (full access, including the status and metrics endpoints).
From the web UI¶
The key management page lives at /settings/keys (reachable when the web UI is enabled and you are signed in as admin or on a trusted network). It shows a masked list of existing keys - truncated public ID, name, scopes, and the created/revoked timestamps in your local timezone - and a create form. The page never renders raw key material or a full hash.

- Create. Enter a name, choose the scopes (webhook is pre-checked), and submit. The new raw key is revealed once in the result panel.
- Revoke. Revocation is by public ID (the raw key is unrecoverable). A revoked key is shown struck through with its revocation time; it no longer authenticates.
Create and revoke are CSRF-protected (a same-origin form submission with the double-submit token), so direct curl POSTs without the page-issued cookie are rejected.
From the CLI¶
# Create a key (prints the raw key once on stdout - save it).
canticle keys create --name lidarr --scope webhook
canticle keys create --name admin-tool --scope admin # repeat --scope to grant several
# List keys: tab-separated public ID, name, scopes, and revoked-at (empty if active).
canticle keys list
# Revoke a key by passing its raw value.
canticle keys revoke <raw-api-key>
keys create prints only the raw key on stdout, so you can capture it directly (for example KEY=$(canticle keys create --name lidarr)). With no --scope flag the key defaults to the webhook scope. All three subcommands accept --config to point at a non-default config file. Under Docker, run them via docker exec, for example docker exec canticle canticle keys list.
The CLI and the MXLRC_WEBHOOK_API_KEY environment variable are independent ways to supply webhook keys: env/TOML keys (server.webhook_api_keys) are static configuration, while keys create mints managed keys in the database that can be listed and revoked individually.
Windows¶
Download the signed .zip archive for windows/amd64 from the GitHub releases page. Extract canticle.exe to one of:
%LOCALAPPDATA%\mxlrcgo-svc\- user-mode install; no administrator rights required.C:\Program Files\mxlrcgo-svc\- system-wide install; requires administrator rights.
Add the chosen directory to your PATH so canticle is reachable from any shell.
Manual run. To start the server from a terminal (useful for initial testing):
set MUSIXMATCH_TOKEN=YOUR_TOKEN
set MXLRC_WEBHOOK_API_KEY=mxlrc_your_webhook_key
canticle serve --listen 127.0.0.1:3876
Or use a config file to keep credentials out of the shell environment:
NSSM service installation¶
NSSM (the Non-Sucking Service Manager) wraps any executable as a Windows service with automatic restart, reliable start/stop, and log capture. Download a release build from nssm.cc and place nssm.exe somewhere on your PATH.
Install the service. Run the following from an elevated (Administrator) Command Prompt:
NSSM opens a GUI. Fill in the tabs:
- Application tab:
- Path: full path to
canticle.exe, for exampleC:\Program Files\mxlrcgo-svc\canticle.exe - Arguments:
serve --listen 0.0.0.0:3876(orserve --config C:\path\to\config.tomlif you use a config file) -
Startup directory: the directory containing
canticle.exe -
Environment tab. Add one variable per line:
MUSIXMATCH_TOKEN=YOUR_TOKEN
MXLRC_WEBHOOK_API_KEY=mxlrc_your_webhook_key
MXLRC_DB_PATH=C:\ProgramData\mxlrcgo-svc\mxlrcgo.db
Set MXLRC_DB_PATH explicitly so the database lives in a known, writable location rather than depending on the service account's XDG defaults (see Data location below).
- I/O tab. Set Stdout and Stderr to a log file, for example
C:\ProgramData\mxlrcgo-svc\logs\mxlrcgo-svc.log. NSSM rotates these automatically.
Click Install service, then start it:
Manage the service:
nssm start mxlrcgo-svc
nssm stop mxlrcgo-svc
nssm restart mxlrcgo-svc
nssm status mxlrcgo-svc
nssm remove mxlrcgo-svc confirm # uninstall
To update the configuration after installation, run nssm edit mxlrcgo-svc from an elevated prompt.
Data location¶
Without an explicit MXLRC_DB_PATH, canticle resolves storage paths via XDG base directories. On Windows these defaults resolve to:
| Item | Default path |
|---|---|
| Config file | C:\Users\<user>\.config\mxlrcgo-svc\config.toml |
| Database | C:\Users\<user>\.local\share\mxlrcgo-svc\mxlrcgo.db |
A service account resolves these under its own profile, which may not be immediately obvious. Set MXLRC_DB_PATH (and --config) explicitly in the NSSM environment tab to put data in a known, persistent location (for example C:\ProgramData\mxlrcgo-svc\).
Removing or uninstalling the NSSM service does not delete the database or config file. Remove them manually for a clean uninstall.
SmartScreen note¶
Even signed binaries can trigger the "Windows protected your PC" prompt on first launch when the executable's download reputation is too low. If you see this:
- Click More info.
- Click Run anyway.
This prompt should not appear again after the first run. See issue #183 for background on code signing.
Native packages¶
The .deb, .rpm, and .apk packages install the binary, a service unit, and an example config. They do not enable or start the service on install; you must do that manually after setting your token.
System user and state directory¶
The post-install script creates a mxlrcgo-svc system user and group (no login shell, no home-directory creation), then initializes /var/lib/mxlrcgo-svc (mode 0750, owned by mxlrcgo-svc:mxlrcgo-svc). The service unit sets MXLRC_DB_PATH=/var/lib/mxlrcgo-svc/mxlrcgo.db and MXLRC_LOG_FORMAT=json in its environment.
Hardening¶
The systemd unit runs with ProtectSystem=strict, ProtectHome=true, PrivateTmp=true, and NoNewPrivileges=true. Only /var/lib/mxlrcgo-svc is writable. The OpenRC service on Alpine enforces the same ownership via start_pre.
Config file¶
Copy the example and set your token before starting the service:
sudo cp /etc/mxlrcgo-svc/config.example.toml /etc/mxlrcgo-svc/config.toml
sudo editor /etc/mxlrcgo-svc/config.toml
The service reads /etc/mxlrcgo-svc/config.toml automatically when MXLRC_DB_PATH is the native-package default. Pass --config /etc/mxlrcgo-svc/config.toml explicitly if you override the database path.
Service management¶
systemd (Debian / Ubuntu / RHEL / Fedora / Rocky):
sudo systemctl enable mxlrcgo-svc # start on boot
sudo systemctl start mxlrcgo-svc
sudo systemctl stop mxlrcgo-svc
sudo systemctl restart mxlrcgo-svc
sudo systemctl status mxlrcgo-svc
sudo journalctl -u mxlrcgo-svc -f # follow logs
OpenRC (Alpine):
sudo rc-update add mxlrcgo-svc default # start on boot
sudo rc-service mxlrcgo-svc start
sudo rc-service mxlrcgo-svc stop
sudo rc-service mxlrcgo-svc restart
sudo rc-service mxlrcgo-svc status
Data preservation on removal¶
Package removal stops the service but intentionally preserves /var/lib/mxlrcgo-svc (the SQLite database and any config) and the mxlrcgo-svc system user. This means reinstalling or upgrading the package keeps your cache and settings. Remove the directory manually for a clean uninstall:
Recording enrichment¶
Recording enrichment reads the ISRC, MusicBrainz recording ID, and duration from each file's audio tags and passes them to the matcher to disambiguate results (for example, telling two same-titled recordings apart). It is on by default.
You can control it at three levels, resolved with the precedence CLI flag > per-library setting > global default:
- Global default (
config.toml):[enrichment] enabled = true(envMXLRC_ENRICHMENT_ENABLED). Defaulttrue. Setfalseto skip enrichment everywhere unless a library or run opts back in. - Per library:
canticle library add/update --enrich(force on) or--enrich=false(force off). Omit the flag to inherit the global default. - Per run:
canticle scan --enrichor--no-enrichoverrides both for that single scan (the two flags are mutually exclusive). The serve-mode scheduler has no per-run flag; it resolves per library against the global default.
When enrichment is off for a track, the scanner skips ISRC, MBID, and duration extraction as a unit, and the track keeps the duration_bucket = 0 cache fallback (no behavior regression). The same switch decides whether a scan writes the tag cache, and the worker follows the global setting: with enrichment off, a file with no cache row is opened on every scan (see What a scan reads). A per-library or global change only affects scans run after the change; it does not restamp already-scanned rows.
What a scan reads¶
A scan of a library that has not changed reads no audio file contents, apart from the cases listed under "What still opens a file" below. Settled-file adoption can also read lyric files (see "Settled-file adoption" below). It still lists each directory, checks each file's size and modified time, and looks for the lyric file beside it. Whether those checks wake spun-down disks depends on your operating system keeping directory metadata in memory, which Canticle does not control.
Two things make this possible. A lone .txt lyric file is no longer read on an ordinary scan. And the tags of a file that has no lyric file yet are taken from the tag cache (the audio_metadata table) when the cache row is current, so the audio file is not opened.
What a scan no longer opens¶
- A lone
.txtlyric file is read only when the scan runs with--upgradeor--update, which can reopen it. Otherwise an instrumental marker and plain unsynced lyrics are skipped the same way, and neither is read. - The audio file of a track that has no lyric file yet, when its cache row matches the file's path, modified time and size and was written by the current tag reader and duration reader. The scan builds the track from the row.
What still opens a file¶
- A file whose modified time or size changed since its cache row was written.
- A file with no current cache row: never indexed, or indexed before the tag-reader version existed (see "What fills the tag cache").
- Any file, while an embedded-lyrics mode is on (
respectorextract, see Configuration). Embedded lyrics are deliberately not cached, so the scan has to open each such file. - A file whose duration the parser tried and failed to read. It is never cached, so it is read again on every scan.
- A scan that can reopen a settled file (
--upgradereads each lone.txt,--updatealso reopens settled.lrcfiles). The.txtis read, then the audio tags come from the cache if the row is current. - A settled file (one with a lyric file beside it) that the scan index has not seen: its tags are read once so it can be indexed. A settled file with a
.lrc, with recording enrichment on and no cached duration for its current version, is opened once for a duration read. - A file whose tags failed to read before and has not changed is skipped, not opened.
- Settled-file adoption's lyric-file read. After each scan, a file that already has a lyric file beside it and no queue row gets a
donequeue row, and the lyric file is read to classify it (synced, unsynced or instrumental). That is a read of the lyric file, not the audio file. See "Settled-file adoption" below.
Settled-file adoption¶
After each scan of a library, a file that has a lyric file beside it and no queue row is given a done queue row, so the upgrade sweep, the timing recheck and the reports can see files Canticle never fetched. A file that already has a queue row for the same artist and title is linked to it and nothing is read.
- Read budget. A scan reads at most 2000 lyric files per library to classify them. Adopted files leave the backlog, so later scans continue until it drains. Linking to an existing row and files with no lyric file read nothing and do not count against the budget.
- A file that cannot be classified (an unreadable or unrecognized lyric file) is counted as
unreadable, left unadopted, and read again on a later scan. It counts against the budget each time. - What an adopted row records. No source lane or upstream (Canticle did not fetch it).
completed_atis the adoption time, and a synced row also has its word-timing recheck time set, so it waits the sweep's normal interval before it is a candidate. - Reopen flags.
scan --upgradeandscan --updatecan re-queue an adopted file once, within each flag's scope. After a worker takes its row, or if you edited the file in Canticle or marked it instrumental by hand, no flag re-queues it. See the CLI reference. - Backup. Each adoption is appended and synced to
adopt-settled-backup.jsonl, in the same directory as the database. The file is created on the first adoption, so a scan that adopts nothing leaves none. If the backup cannot be written, the pass stops.
What fills the tag cache¶
scan index-metadata --yesreads files that have no current row. It skips a file whose row matches its path, modified time and size, so it does not refresh a row written before the tag-reader version existed.- The scan's own read. When a scan has to read a file, it writes the row from that read, so the next scan does not open the file. It writes only with recording enrichment on for that scan. It declines when the duration parse failed, when the second read that recovers repeated artist values on FLAC and Ogg Vorbis files hit an I/O error, or when the file changed while it was being read. With enrichment off the scan uses existing rows but writes none.
- The worker's fetch-time read (serve mode only). Before a fetch the worker looks up the file's row and, on a miss, reads the file and writes the full row. It follows the global
[enrichment] enabledsetting, not a per-library override, and declines in the same three cases as the scan. The worker accepts a row without checking the tag-reader version, so it does not re-read a file for that reason alone.
After an upgrade that adds the tag-reader version, each existing row misses once. The first scan with recording enrichment on re-reads those files and rewrites their rows. With enrichment off the rows stay stale and the files are opened on every scan.
A retag that keeps the modified time is not seen¶
The cache decides a file is unchanged from its path, modified time and size. A tagger that preserves the modified time and rewrites tags inside the file's existing padding changes neither, so the scan keeps the cached artist and title, and the track keeps its old identity.
To force a re-read, touch the file (touch "/path/to/track.flac"). The new modified time makes the row a miss on the next scan, which reads the file and rewrites the row. Retaggers that change the file size or the modified time are seen without any action.
Known limits¶
- With an embedded-lyrics mode on, every file without a lyric file is opened on each scan.
- With recording enrichment off (
[enrichment] enabled = falseor--no-enrich), the scan serves rows that exist but writes none, and the worker writes none either, so a file with no row is opened on each scan. - Directory metadata must stay in memory for an unchanged scan to leave the disks asleep, as above.
Log lines¶
After a scan that adopted, linked or skipped any file, Canticle logs one info line, scan: adopted settled files into the queue, with the counts inserted, linked, not_settled, already_linked, unexpected, no_sidecar, unreadable, no_identity and write_failed, plus budget_hit (true when the read budget ended the pass early). The line carries counts only, never a path or title. A scan with nothing to report logs nothing.
On an ordinary scan, an instrumental marker with no reopen now logs skipping file, unsynced lyrics exist at debug level, where it used to log skipping file, instrumental marker settled.
Each scan also logs one scan identity cache line with the counts hits, misses, lookup_errors, duration_bank_errors, recorded, record_errors and multivalue_skips. It is at debug level unless a lookup, duration write or row write failed, and then it is a warning that also carries a first_error cause with no file path. At the default (info) level you see it only on failure.
Instrumental detection¶
The optional instrumental-detection sidecar samples each track's audio with ffmpeg and asks an external classifier whether it is instrumental, writing an instrumental marker on a provider miss. It runs only when a classifier URL is configured ([instrumental_detector] classifier_url). Because it costs an ffmpeg sample plus an inference call per track, you may want it on a small curated or soundtrack-heavy library but off on a large bulk one.
For how the detector decides (the three-gate model), the YAMNet sidecar setup and {mean,max} contract, deploy ordering, and threshold tuning, see the dedicated Instrumental Detection reference. This section covers only the enable/override controls.
You control it at three levels, resolved with the precedence CLI flag > per-library setting > global default:
- Global default (
config.toml):[instrumental_detector] enabled(envMXLRC_INSTRUMENTAL_DETECTOR_ENABLED). Defaultfalse. - Per library:
canticle library add/update --detect-instrumental(force on) or--detect-instrumental=false(force off). Omit the flag to inherit the global default. - Per run:
canticle scan --detect-instrumentalor--no-detect-instrumentaloverrides both for the tracks enqueued by that scan (the two flags are mutually exclusive). serve has no per-run flag; it resolves per library against the global default.
The decision is resolved and stamped onto each work-queue item when it is enqueued (like the provider-set version), so a per-library or global change only affects tracks enqueued after the change. Tracks queued before per-library detection existed carry no decision and fall back to the global default at processing time. If an item requests detection but no classifier_url is configured, the worker logs an error and proceeds without detection (it never silently skips).
Filesystem watcher (optional, low-latency scans)¶
By default, serve only scans on the scheduler's tick (--scan-interval, default 900s), so a new track dropped into the library waits up to that interval before lyrics are fetched. An optional filesystem watcher reacts within seconds for the common single-host case. It is disabled by default and configured entirely through environment variables:
| Variable | Default | Purpose |
|---|---|---|
MXLRCGO_WATCH_ENABLED |
false |
Master switch. When unset/false, behavior is exactly as before. |
MXLRCGO_WATCH_DEBOUNCE_MS |
2000 |
Quiet period after the last event before a directory is scanned. Coalesces the event storms that taggers (Beets, Picard) produce when rewriting an album. |
MXLRCGO_WATCH_MAX_DIRS |
100000 |
Safety cap. Startup fails loudly if the configured roots contain more directories than this, rather than silently exceeding the kernel watch budget. |
When a file appears or changes, the watcher scans the affected directory (and its subtree, up to the configured scan depth) under the owning library and enqueues any cache misses at scan priority.
The watcher is best-effort and in addition to the periodic scan, never a replacement:
- Bind-mounted volumes, NFS, SMB, and Docker Desktop on macOS frequently drop or never emit filesystem events.
- Events that fire while the container is down are lost; there is no replay. The periodic scan reconciles them.
- On Linux, very large libraries may require raising the inotify watch limit, e.g.
sysctl fs.inotify.max_user_watches=524288.
Watcher-primary mode¶
Because the periodic scheduler remains the source of truth, you can run the watcher as the primary trigger and demote the periodic scan to a long reconcile backstop. Enable the watcher and raise the interval, e.g.:
The startup scan always runs regardless of the interval, so initial reconciliation is guaranteed. Do not set the interval to 0 (scan-once) unless you have verified the watcher actually delivers events on your filesystem, because then nothing reconciles missed events.
Verifying watcher events¶
The watcher emits INFO "watcher started" at boot (with library and directory counts). To confirm it is actually receiving events, enable debug logging (MXLRC_LOG_LEVEL=debug) and touch a file under a library root, then watch for DEBUG "watcher: event received" and a follow-up scan. If nothing appears, your filesystem is not delivering inotify events to the container and you must keep the periodic scan as the source of truth. Common offenders: Unraid /mnt/user (FUSE/shfs) bind mounts, NFS without NFSv4.1 delegations, SMB/CIFS, and Docker Desktop's virtualized mounts.
Shell completion¶
canticle completion <bash|zsh|fish> prints a sourceable completion script that completes subcommands, flags, and configured library names (the last queried live from the database, degrading gracefully when it is absent):
source <(canticle completion bash) # bash (e.g. in ~/.bashrc)
source <(canticle completion zsh) # zsh (e.g. in ~/.zshrc)
canticle completion fish > ~/.config/fish/completions/canticle.fish
The scripts call a hidden __complete handler; library-name completion never creates the database.
Security considerations¶
Web UI and admin bootstrap. The browser UI is off by default; enabling it and bootstrapping the first admin on a headless Docker/Unraid host is covered in Web UI enablement under the Unraid section. The short version: enable with MXLRC_WEB_UI_ENABLED=true, bootstrap with MXLRC_WEBAUTH_ADMIN_USER / MXLRC_WEBAUTH_ADMIN_PASSWORD (8-char minimum, idempotent), then rotate the password and remove those env vars after first login. The interactive /setup page also works headlessly now (it is open until the first admin exists), but env-bootstrap closes that window before the listener ever serves the page, so it remains the safer headless path.
Session cookie and TLS. The browser session cookie (mxlrc_session) has its Secure flag set automatically when the connection is TLS - either a direct TLS listener or a trusted reverse proxy that sets X-Forwarded-Proto: https. On a plain-HTTP deployment the Secure flag is off and the cookie is sent in cleartext, which exposes it to network interception. If the web UI is reachable from outside your local machine, run it behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik) or enable the built-in TLS listener ([server.tls] in config.toml). See README.md for TLS configuration options.
Inspection commands¶
The queue and scan subcommands expose the durable work queue and persisted
scan results so you can debug what the service is doing without opening the
SQLite database by hand.
# List the next 50 work_queue rows.
canticle queue list
# Filter by status; failed and deferred are also exposed as convenience subcommands.
canticle queue list --status pending --limit 100
canticle queue failed
# List deferred rows: benign misses (a track Musixmatch has no lyrics for yet)
# waiting out a fixed cooldown before re-check. These are NOT failures and are
# kept out of `queue failed`.
canticle queue deferred
# Reset a single failed row back to pending. Refused if the row is not failed
# (a deferred row is refused; let it re-check on its own, or re-trigger via webhook).
canticle queue retry 42
# Delete completed rows. Without --yes, prints what would be deleted.
canticle queue clear --done
canticle queue clear --done --yes
# List persisted scan_results, optionally filtered by library (name or id) and status.
canticle scan results
canticle scan results --library Music --status pending
canticle scan results --library 1 --limit 200
# Delete every scan_results row for a single library. Without --yes, prints what would be deleted.
# The library row itself is left intact.
canticle scan clear --library Music
canticle scan clear --library Music --yes
Mark a track instrumental by hand¶
queue mark-instrumental marks a track instrumental when you know the detector is wrong. It replaces the track's .lrc and .txt files with an instrumental marker. The row is then protected: a scan, an upgrade or a re-fetch will not replace the marker. queue unmark-instrumental removes the mark and the marker file, and puts the track back in the queue so it is fetched again.
# Pick tracks by work item id (repeat --id) or by audio file path (repeat --path).
# Without --yes this only prints counts of what would change.
canticle queue mark-instrumental --id 42 --id 43
canticle queue mark-instrumental --path /music/Artist/Album/track.flac --yes
# Undo it. The track is queued again and fetched like any other.
canticle queue unmark-instrumental --id 42 --yes
--path finds the work item by the audio file path exactly as the scan recorded it; a path through a symlink, or a relative path, is counted as "not found". A path or id with no work item is counted as "not found". A track that is being processed right now is counted as "in flight"; run the command again later. One failure does not stop the others. The exit status is 1 if any selected row failed, was not found or was in flight, and 0 only when every row was marked, unmarked, already marked or not marked.
The output is counts only. It never prints a library path, artist, title or lyric text; the one path it prints is the backup file's.
Before a real run changes a file, it appends the old file to a JSONL backup. The default is <db-dir>/instrumental-mark-backup-<timestamp>.jsonl (instrumental-unmark-backup-... for unmark), or the file named by --backup, which must not be a .lrc, .txt or .elrc file or sit inside a library root (the run would replace it). The command prints the backup path when it finishes.
Each line is one JSON record with four fields: op (mark or unmark), work_item_id, path (the file that was replaced or removed) and content (its bytes, base64 encoded). Unmarking does not restore the old lyrics, because the track is fetched again. To put a file back by hand, decode the content of its mark record and write the bytes to path:
jq -r 'select(.op=="mark") | [.path, .content] | @tsv' backup.jsonl
# for one record: printf '%s' "$CONTENT" | base64 -d > "$PATH_FROM_RECORD"
Mark a track's lyrics as wrong¶
scan mark-wrong is for a track whose fetched lyrics are another song's words. It backs up the track's .lrc and .txt files, removes them, remembers those exact words as blocked so they are never accepted for this track again, and queues the track to be fetched again. scan list-blocks shows what is blocked. scan unblock lifts a block.
# Pick the track by work item id. Without --yes this only prints what would change.
canticle scan mark-wrong --id 42
canticle scan mark-wrong --id 42 --yes
# Counts of blocks, by lane and by upstream. Add --tail for one line per block.
canticle scan list-blocks
# Lift one block by block id, or every block on a track by work item id.
canticle scan unblock --id 7 --yes
canticle scan unblock --work-item 42 --yes
# Clear the blocks of tracks that are no longer in the queue.
canticle scan unblock --orphans --yes
--orphans removes only blocks whose track has no work item any more (for example after the file was deleted and pruned); a block on a track that is still queued is kept. It is a dry run without --yes, prints counts only, and cannot be combined with --id or --work-item. Add --tail to see each block's identity.
A track whose file was pruned (for example while a share was unmounted) and comes back on a later scan loses its block if --orphans was applied in between, so the blocked lyrics can return. Look at the dry run first.
Tracks are chosen by id only. The output is counts and outcome words; it never prints a library path, artist, title or lyric text. --tail is where detail appears: mark-wrong --tail prints each file backed up, and list-blocks --tail prints each block with its identity. The exit status is 1 if the track or block is not found, is being processed right now, has no lyric file on disk, is marked instrumental by hand, or is failed or unavailable (revive it first); nothing is changed in those cases.
Before a real mark-wrong removes a file, it appends the file to a JSONL backup. The default is <db-dir>/mark-wrong-backup-<timestamp>.jsonl, or the file named by --backup, which has the same limits as queue mark-instrumental --backup. Each line has op (mark-wrong), work_item_id, path, content (the bytes, base64 encoded) and meta. scan unblock does not restore a lyric file; the next fetch decides what the track gets. To put a file back by hand:
jq -r 'select(.op=="mark-wrong") | [.path, .content] | @tsv' backup.jsonl
# for one record: printf '%s' "$CONTENT" | base64 -d > "$PATH_FROM_RECORD"
Realign orphaned sidecars¶
When an audio file is renamed but its .lrc / .txt lyric sidecar is not, the
sidecar is orphaned: it no longer shares a stem with any audio file, and a later
scan re-fetches lyrics that already exist on disk. canticle realign re-attaches
those orphaned sidecars to their audio, changing only the stem and never the
extension (a synced .lrc stays .lrc; an instrumental .txt marker stays
.txt and is never promoted).
Each orphan is classified into one of these confidence tiers, and only clean matches are ever renamed:
- exact - the orphan's
[isrc:]/[mbid:]header uniquely matches one audio file's embedded ISRC/MBID (matched inidentity_keysorder, defaultmbidthenisrc). - heuristic - exactly one orphaned sidecar and exactly one sidecar-less audio
file in the same directory, whose names match closely enough (a Jaro-Winkler
name guard at
min_confidence) and distinguishably enough: the pair must also beat the orphan's best score against every other audio file in that directory - including ones that already have a sidecar - bymin_margin. Only the title is compared, never the artist; inside an album directory every track shares the artist, so including it would let two unrelated songs score as a match. If neither the sidecar header nor the audio tags yield an artist/title, the name check is skipped and the lone pair is matched positionally. - heuristic-nm - opt-in (
name_match = true), for a directory with multiple orphaned sidecars and multiple sidecar-less audio files (a folder of renamed tracks) - the shape the single-candidate heuristic tier above cannot resolve. Every orphan is scored against every remaining candidate; a pairing is accepted only when it clearsmin_confidenceand the orphan's best score beats its runner-up by at leastmin_margin. Off by default, so such a directory stays ambiguous until you opt in. - ambiguous - zero or multiple candidates on either side, or a pairing too
close to call. Both name tiers can report a near-tie:
heuristicwhen the pair fails to beat the orphan's best rival in the directory bymin_margin,heuristic-nmwhen the best score fails to beat its runner-up by the same. Reported and skipped, never guessed. - conflict - contradictory signals (multiple exact matches) or a destination sidecar that already exists. Reported and skipped, never clobbered.
# Preview what would change across all libraries (dry run, the default).
canticle realign
# Apply the moves; every rename is recorded in a JSONL backup first.
canticle realign --yes
# Limit to a single library, and choose the backup path.
canticle realign --library Music --yes
canticle realign --yes --backup /data/realign-undo.jsonl
The applied-move backup (<db-dir>/realign-backup-<timestamp>.jsonl, or the
--backup path) records {"old_path","new_path","library_id","method"} per
line; swap old_path/new_path to undo. The behavior is tuned by the
[realign] config section - notably
require_provenance (restrict applied moves to the exact tier), cross_directory
(let an exact match move a sidecar between directories), min_confidence
(the heuristic name-guard floor), name_match (enable the N:M matcher), and
min_margin (the ambiguity-rejection threshold, applied to both
name-similarity tiers).
The exact tier requires ISRC/MBID-tagged audio; libraries whose files carry no
such 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). To
prevent orphaning at the source, Lidarr users can install the
rename hook below.
Word-timing re-check cost¶
output.word_sync_mode changes what a new fetch writes. Tracks that already have a line-synced .lrc keep it, because they are never fetched again. canticle scan reconcile-word-sync queues those tracks so a running serve worker asks the providers for word timings.
Per track: at most one Musixmatch request (the word timings come with the lyrics call, not a second one). Only when Musixmatch has no word timings, at most two Petit Lyrics requests. The lyrics cache is not used, so every re-checked track costs at least one request. One case costs a little more: a track that Musixmatch fetched without word timings while Petit Lyrics was also configured was never asked of Petit Lyrics, so it stays unmarked and the re-check asks Musixmatch again (one paced request) before Petit Lyrics.
Time: the worker handles one track at a time at its poll interval (server.work_interval_seconds when set, otherwise api.cooldown; 60 seconds in a typical production setup), so the minimum drain time is candidates x interval:
| candidates | minimum drain at 60 s |
|---|---|
| 1,000 | 60,000 s = 16.7 hours |
| 5,000 | 300,000 s = 3.5 days |
| 10,000 | 600,000 s = 6.9 days |
| 14,000 | 840,000 s = 9.7 days |
These are floors. Re-checks wait behind all fresh work and due retries. While they wait they show as deferred rows, so deferred counts rise during a run. Read your own count from the dry run (it prints candidates= and the drain estimate at your configured interval; a serve --work-interval flag is not visible to it), then size the run with --completed-before, --limit, and --library.
Keeping it current. The command re-examines the tracks that are settled when you run it. The [word_sync_recheck] section (off by default) is the serve-mode sweep that does this continuously, with batch capping how many tracks wait at once. Enable it instead of re-running the command to pick up newly settled tracks. The same per-track cost applies, and the tracks it queues count as deferred while they wait.
What changes on disk. A re-check writes only a result that carries word timings and passes the same timing check as any fetch. It never replaces a .lrc with a .txt or removes one. When word timings do land, the .lrc is rewritten from that provider's lines, so the line text or timing can differ slightly from the file you had. Emby and similar players pick the change up on their next library scan. Music Assistant does not: it re-reads lyrics only when the audio file itself changes (#505), so it keeps showing the old lyrics.
Reports workspace¶
The web UI exposes read-only report views under the Reports section. Every report runs a live database query at request time; there is no pre-aggregation or caching. The reports require the web UI to be enabled (web_ui_enabled = true).
Queue summary¶
Retired. The Queue page (/queue, Queue in the sidebar) lists every bucket (Retrying, Errored, Queued, Finished, Settled (upgradable), Given up) with its count and links each to its track list. The old /reports/queue-summary address redirects there. For a rising Errored count see Failure analysis; for a large Retrying count see Deferred misses.
Recent outcomes¶
Lists the most recently completed tracks, newest first, with:
- Artist, album, and title.
- Completion timestamp.
- Provider lane that served the result (none for a file a scan adopted).
- An Actions column with the Mark instrumental and Lyrics are wrong icons; see Web UI: Marking a track.
- Result class: word-synced or line-synced (an .lrc was written; see below), synced (an .lrc was written but its word/line tier was never recorded), unsynced (a .txt of plain unsynced lyrics), instrumental (a .txt instrumental marker, from either audio detection or a provider flag), rejected (the language/script guard refused the fetched lyric; nothing was written), miss (the track exhausted its miss budget with no lyrics found), or unknown (a row that predates result classification and cannot be reclassified from anything on disk).
Word-synced vs. line-synced (#627). Both tiers write an .lrc, but they are not the same result:
- word-synced means word-level timing landed on disk - the terminal tier. Nothing further to gain from a re-fetch or a word-sync recheck.
- line-synced means the .lrc on disk has line-level timing only. Still eligible for a later word-sync recheck to promote it.
- synced (rendered as plain "synced" text with a muted grey tier badge - see the Dashboard section above) means the tier is not recorded, or was recorded but the row was later remediated by the timing guard (quarantined or demoted; such a row's stale tier is deliberately not shown). A new completion always records its tier, whichever lane or cache served it and whatever word_sync_mode is set to, so this bucket is mostly tracks completed before tiers were recorded. A row currently mid word-sync recheck is flipped out of done for the duration of the recheck, so it shows up neither in this table nor in the Results tiles until it settles. To resolve the unrecorded rows, run canticle scan reconcile-sync-tier (dry-run by default; add --yes to apply): it classifies each existing .lrc from the file on disk, with no provider requests, and leaves a row whose sidecar is missing or unreadable unrecorded rather than guessing. A word-sync recheck does not resolve this bucket; it re-asks providers for word timing, which is a different question.
Source of truth. The tier is read from the recorded sync_tier database column (word = word-synced, line = line-synced, anything else or not recorded = tier unknown), which records what the .lrc on disk actually is: it is stamped from the written file when a track completes, and scan reconcile-sync-tier fills it for existing files by classifying them. It is not the word_timing_state column, which records whether a provider answered a word-timing request, not what the file contains. The reports read the recorded column rather than re-parsing every sidecar on each page load: one query against work_queue (it classifies every status = 'done' row, so it reads the completed population rather than seeking an index), a few milliseconds at a library's typical size, following the same recorded-at-completion pattern as the existing outcome_type column.
Note: .txt results group plain unsynced lyrics and audio-detected instrumentals together. Use the Instrumental inventory report to isolate audio-detected instrumentals.
Provider effectiveness¶
Shows per-lane hit/miss counts and a true per-track hit-rate for each provider lane. The hit-rate is computed as hits / (hits + misses) where a "hit" means the lane served the winning result and a "miss" means the lane was tried but did not win (including being outcompeted by a later lane in ordered mode).
No-backfill caveat. This report reads the lane_attempts table, which was added in a later migration. Traffic that predates the migration has no lane-level records, so the report shows an empty state until new attempts accrue after the migration. This is expected; no historical backfill is possible.
Lane-attribution caveat (from v1.7.0). On a deployment whose [providers].primary was not musixmatch, rows this table recorded during that window name the wrong lane, from the same defect described under the metrics endpoint: the primary lane's name came from a hardcoded constant, so the primary provider's work was recorded against musixmatch. Unlike the /metrics counters, these rows do carry per-track identity and are therefore correctable in principle, but no correction has been applied.
How far you can read through the misnaming depends on the configuration. Where the primary was the only lane, or where the fallbacks were all other providers, it is a uniform relabel: the per-track hit and miss structure is intact and only the name on it is wrong, so a reader who knows which provider was primary can read through it, and this report stays the better instrument for the period. Where musixmatch was also configured in fallback_order behind a different primary, it is not: the primary lane's hardcoded name and the real Musixmatch fallback collide on one label, and lane_attempts is UNIQUE(queue_id, lane), so the two lanes' attempts upsert over each other rather than being recorded separately. A track's rows can also mark that shared label as a hit when the primary won, because a lane's hit is decided by comparing the winning lane's NAME. In that configuration the per-lane split for the window is not recoverable from this table either, and neither instrument answers a per-lane question about it.
Instrumental inventory¶
Lists every work queue item the audio detector confirmed as instrumental, joined to the source file path from scan results. Each item shows: - Artist, album, and title. The album is the file's own tag where the scan recorded one. - Source file path (empty for items enqueued via CLI without a library scan link). - Whether per-item detection was requested explicitly, inherited from a library setting, or fell back to the global default.
Use this to audit which tracks the detector marked instrumental and whether they match expectation.
Failure analysis¶
Lists FAILED work queue items grouped by error reason, with a count per group, ordered most-frequent first. This lists the rows behind the dashboard "failed" count, except legacy non-fetch rows (empty reason, zero attempts), which are excluded per #789, so the totals can differ by those rows. These rows hit a hard error and may need manual intervention (canticle queue retry <id>). Benign misses are a separate report, below.
Deferred misses¶
Lists DEFERRED work queue items grouped by reason, most-frequent first. A deferred row is a benign miss (the providers had nothing yet) waiting on its retry schedule, so it self-resolves and needs no action; the report shows where catalog coverage is thin. It is deliberately not part of Failure analysis, so the failed count there stays small and meaningful.