Configuration¶
beetkeeper does not have its own config file. It reads its settings from an optional top-level
beetkeeper: section inside your existing beets config. A plain beets config without that section is
still a valid beets config — beetkeeper only requires the section when you run the server.
You tell beetkeeper which beets config to read via either:
- the
BEETSDIRenvironment variable — the directory holding your beetsconfig.yaml(beets' own convention), or - the
--beetsdir/-bCLI flag — the same directory, passed explicitly (see the CLI).
Example¶
# ... your usual beets config (directory, library, plugins, ...) ...
beetkeeper:
logging:
log_level: INFO
server:
hostname: 0.0.0.0
port: 8337
database:
# beetkeeper's own SQLite db (separate from the beets library db); created automatically on first run.
sqlite_path: /beets/beetkeeper.db
# Optional: require a login for all access (off by default).
auth:
enable_login_protection: true
username: admin
password: change-me
# Optional: let beetkeeper ask your download client where a library entry came from (off by default).
downloader_hook:
base_url: http://qbittorrent:8080
search_endpoint_path: /api/v2/torrents/info
beet_field_to_dl_search_field:
album: name
filepath_json_key: content_path
replace_downloader_paths_prefix: /data/torrents/complete
Settings¶
| Key | Type | Default | Description |
|---|---|---|---|
logging.log_level |
string | INFO |
One of CRITICAL, DEBUG, ERROR, INFO, NOTSET, WARNING. |
logging.log_filepath |
path | — | Write logs to this file instead of stdout. The file is created if missing; its directory must exist. |
logging.log_rotation_max_bytes |
size | 100MB |
Max size of the log file before it is rotated (only used with logging.log_filepath). Accepts units, e.g. 50MiB. |
server.hostname |
string | 127.0.0.1 |
Interface to bind (e.g. 0.0.0.0 to listen on all interfaces; the default binds loopback only). |
server.port |
int | 8337 |
Port the server listens on (must be > 0). |
server.forwarded_allow_ips |
string | — | Reverse-proxy addresses (comma-separated IPs and/or CIDR networks like 192.168.40.0/24, or "*" for all) whose X-Forwarded-* headers the server trusts. The UI renders root-relative URLs and works through any proxy without this; set it behind a reverse proxy so the request scheme and client address reflect the real client (accurate logs, correct absolute URLs anywhere one is ever emitted). Hostnames are not supported — uvicorn compares the raw peer IP. Note that setting this replaces the loopback default, so include 127.0.0.1 if a local proxy is also in play. Unset, uvicorn's default applies (the FORWARDED_ALLOW_IPS env var, else loopback only). |
database.sqlite_path |
path | — | Path to beetkeeper's own SQLite db (created automatically on first run). |
database.auto_upgrade |
bool | true |
Apply pending schema migrations automatically when the server starts (the db file is backed up first). When false, a stale schema fails startup until you run beetkeeper db upgrade. |
auth.enable_login_protection |
bool | false |
Opt-in login protection: when true, every page and API route requires a logged-in session. |
auth.username |
string | — | Login username. Required when enable_login_protection is true. |
auth.password |
string | — | Login password. Required when enable_login_protection is true. |
auth.session_ttl_hours |
int | 168 |
How long a login session stays valid before a new login is required. |
downloads_path |
path | /downloads |
The pre-import staging folder beetkeeper imports from (the container's /downloads mount). The import page's forms and clean-slate sources are pinned to it, and downloader hook paths are mapped into it. |
downloader_hook.base_url |
URL | — | Base URL (with port) of your download client's REST API. Setting it enables the hook; the next three settings are then required. |
downloader_hook.search_endpoint_path |
string | — | Endpoint (relative to base_url) beetkeeper sends GET search requests to. |
downloader_hook.beet_field_to_dl_search_field |
map | — | beets fields of the library entry being looked up → the query param name each is sent as (e.g. album: name). Empty fields are left out. A clean-slate import also carries these fields (flexible attributes only) from the removed entry onto the fresh one. |
downloader_hook.filepath_json_key |
string | — | JSON key in each search result holding the download's path (e.g. qBittorrent's content_path). beetkeeper uses the first result. |
downloader_hook.replace_downloader_paths_prefix |
string | "" |
Path prefix the download client reports which beetkeeper replaces with downloads_path — needed when the two run in containers with different mounts. Leave empty when both see the same paths. |
downloader_hook.api_key |
string | — | Bearer token sent in the Authorization header, if the client's API needs one. |
Login protection¶
beetkeeper is single-user: enabling auth.enable_login_protection gates the whole app behind the one
configured username/password pair (there are no per-route permissions — a logged-in client can do
everything).
- Browsers are redirected to a
/loginpage; a successful login stores the session in anHttpOnlycookie, and a Log out button appears in the navigation bar. - API clients exchange the credentials for a bearer token via
POST /api/auth/login, then send it as anAuthorization: Bearer <token>header (revoke it withPOST /api/auth/logout). - The login endpoints, API docs,
/api/health, and static assets stay reachable without a session.
Sessions are stored (hashed) in beetkeeper's database, so they survive restarts.
Downloader hook¶
beetkeeper only knows where a library entry was imported from when the beetkeeper beets plugin reported that import. Entries imported before beetkeeper was set up show Not recorded on the search page — and without a source folder they cannot be clean-slated (say, to recover a track file beets dropped).
The optional downloader_hook section lets beetkeeper ask your download client instead. It is deliberately
generic — any client with a REST search endpoint can be wired up through config alone:
- beetkeeper looks the entry up in the beets library and takes the fields named in
beet_field_to_dl_search_field, renaming each to its query param name. - It sends
GET <base_url><search_endpoint_path>?<params>(withAuthorization: Bearer <api_key>when set). The full request shape is documented as thesearch-missing-source-pathwebhook in the API docs. - The client answers with a JSON list of results (or a single object). beetkeeper reads
filepath_json_keyfrom the first one and maps the path ontodownloads_pathviareplace_downloader_paths_prefix.
The mapping's keys have a second role: a clean-slate import
carries those fields (flexible attributes only, say a torrent_hash) from the removed entry onto the fresh one,
so it can still be looked up afterwards. The preview lists them under Carried over onto the new import.
Run a lookup through POST /api/import/find_missing_source_path, or the Find via downloader button on
unrecorded search rows. A match is stored as the entry's inferred source path: the search page shows it
labelled (inferred via downloader) — for the album and each of its tracks — with a Clean-slate import
from here link, and a later lookup replaces it. Inferred paths are kept in their own table, apart from
source paths recorded from the beetkeeper plugin's events; a recorded path always takes precedence. After
a clean slate the inference follows the entry to its new beets id (or is dropped if nothing was imported).
beets config edits need a restart
beetkeeper applies your beets config file (and loads the plugins it lists) once, when the server first
opens the library. Edit the file, then restart beetkeeper run for the change to take effect.
Authoritative source
The table above is a friendly summary. For the exact field definitions, validation, and defaults, see the
beetkeeper.settings.user_config
models in the source.
Separate database
database.sqlite_path is beetkeeper's own bookkeeping database — not your beets library database.
It is created (and kept schema-current) automatically by beetkeeper run, or manually via
beetkeeper db upgrade (see the CLI).
The beets plugin (beetkeeper_plugin)¶
The beetkeeper-plugin package provides the beets-side
plugin that pushes library events (imports, removals) to the server's /api/events endpoints — this is
what populates the Beets Events page. It ships inside the Docker image and the standalone binary; a
beet command installed separately needs pip install beetkeeper-plugin in its own environment (see
Installation). Enable it like any beets plugin:
Its own (optional) config section:
beetkeeper_plugin:
# Where to push events. Defaults to `http://127.0.0.1:8337` — matching the beetkeeper server's own
# defaults, right for the usual same-container/same-host setup — so most installs need no section at all.
server_url: http://127.0.0.1:8337
# Only needed when the server runs with `auth.enable_login_protection`; leave unset otherwise.
api_token: your-token-here
| Key | Type | Default | Description |
|---|---|---|---|
server_url |
url | http://127.0.0.1:8337 |
Base URL of the beetkeeper server to push events to. Must be a valid http(s):// URL. |
api_token |
string | — | Bearer token for the push requests, for servers running with login protection. When unset (or blank), pushes carry no auth at all. |
The section is validated when beets loads the plugin (see the
_bk_plugin_settings
pydantic models): a malformed server_url fails plugin load with a clear validation error instead of
silently pushing events nowhere, and blank values are treated as unset (their defaults apply).
Push failures are logged and swallowed — an unreachable beetkeeper server never breaks the beets operation that fired the event.