Skip to content

Configuration

zor edited this page Oct 22, 2026 · 35 revisions

| HEDDOHON_OIDC_ISSUER | no | none | The issuer address of an OpenID Connect provider (Authelia, Authentik, Keycloak, Pocket ID), as its discovery document gives it. Set, the sign-in page offers to sign in through it, and each account links its identity under Settings, Security. See Single sign-on (OpenID Connect). From 0.8.0. | | HEDDOHON_OIDC_CLIENT_ID | with an issuer | none | The client id the provider gave Heddohon. | | HEDDOHON_OIDC_CLIENT_SECRET | no | none | The client secret the provider gave Heddohon. Unset for a public client, which proves itself with PKCE alone. | | HEDDOHON_OIDC_CLIENT_SECRET_FILE | no | none | A file holding the client secret, read in place of the variable above (a Docker secret). | | HEDDOHON_OIDC_LABEL | no | single sign-on | The provider's name on the sign-in page's button, "Sign in with" the label. |

Running it

Docker Compose

docker compose up -d
docker compose logs heddohon

From 0.7.0 nothing has to be filled in first. The log shows a setup code; open http://localhost:3000, enter it, and give the address of your music server. See Without a .env: the first-run setup. Then sign in with your Navidrome or Jellyfin account.

To configure it by environment variables instead, and never see the setup page:

cp .env.example .env
# Put a real secret in it:
echo "HEDDOHON_SECRET=$(openssl rand -base64 48)" >> .env
# Point it at your music server(s):
#   HEDDOHON_SUBSONIC_URL=http://10.0.0.10:4533
#   HEDDOHON_JELLYFIN_URL=http://10.0.0.10:8096

docker compose up -d

Docker

docker run -d --name heddohon \
  -p 3000:3000 \
  -v heddohon-data:/data \
  -e HEDDOHON_SECRET="$(openssl rand -base64 48)" \
  -e HEDDOHON_SUBSONIC_URL="http://10.0.0.10:4533" \
  -e ORIGIN="https://music.example.com" \
  heddohon:latest

Unraid

Heddohon is listed in Community Applications: ca.unraid.net/apps/heddohon-0bp7lm80vkr69w. Search for Heddohon on the Apps tab of the Unraid server and install it from there; the form that opens holds the settings below.

The template behind the listing is in this repository at templates/heddohon.xml, with the repository profile CA reads beside it at ca_profile.xml. Both point at raw URLs on this repository, which have to resolve without a login for CA to render the icon and the readme.

From 0.7.0 the template requires only the port and the appdata path. Left empty, the secret, the music server and the public address are asked for by the first-run setup: open the WebUI and enter the code from the container's log. Set them in the form instead to skip the setup.

ORIGIN, which the form calls Public URL, is the address Heddohon is opened at, exactly as the browser shows it: http://192.168.1.10:3000 on the LAN, or https://music.example.com behind a reverse proxy. Left empty, the setup saves the address it was opened at, and an administrator can add or change addresses under Settings, Storage, This server. On a saved http:// address on the local network the sign-in cookie is sent without Secure, so HEDDOHON_COOKIE_SECURE need not be set.

The template sets PUID=99 and PGID=100, the owner of Unraid's appdata. The image's entrypoint gives /data to that user as the container starts and runs the server as it, so the appdata folder needs no chown, including one Docker created as root. A container made from the template before 0.7.0 has --user 99:100 under Extra Parameters and keeps it; that still works, and the folder then has to belong to 99:100.

Behind a reverse proxy

Set ORIGIN to the public URL or form submissions will be rejected by the CSRF origin check. Caddy, for example:

music.example.com {
	reverse_proxy heddohon:3000
}

Audio is streamed through the proxy, so allow long-lived responses and do not buffer whole responses to disk. In nginx that means:

proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;

A proxy that asks for a sign-in before it passes a request on (Authelia, Authentik, Pangolin, Cloudflare Access) works with the browser as it is. The desktop app and the Android app need a little more: see Apps. For the Android app to open without an address bar, the proxy has to let /.well-known/assetlinks.json through without a sign-in.

From source

npm install
npm run dev     # development
npm run build && npm start   # production

On Node 22 or later.

Images and tags

ghcr.io/zorcerer/heddohon and zorcererd/heddohon, for linux/amd64 and linux/arm64 (a Raspberry Pi 4 or 5 on a 64-bit OS, an ARM NAS, an Ampere server); 32-bit ARM is not built. latest is the newest release. dev is built once a day from the dev branch when it has changed and passed the test suites; it can break, and a database it has migrated may not open in the release before it.

The data directory is given to the user the server runs as when the container starts (PUID and PGID, or its owner), so a folder Docker created needs no chown.


Without a .env: the first-run setup

From 0.7.0, a Heddohon started without HEDDOHON_SECRET or without a music server's address prints a setup code to its log and serves a setup page in place of every other page. The page takes the code, the address and name of a Navidrome, Subsonic or Jellyfin server (with a button that checks whether one answers there), the address Heddohon is opened at, the size of the cover cache, the default theme and three switches, and makes the secret.

What it saves is setup.json in the data directory, readable by the server's user alone. A variable in the environment takes precedence over a saved value. The last step shows the secret once, with the line to set it as HEDDOHON_SECRET, and can then take it out of setup.json; the server keeps it in memory until it restarts. While the secret is in the data directory, keeping that directory private is the operator's responsibility: for Navidrome and Subsonic the key opens each account's password, and for Jellyfin an access token. SECURITY.md has the details. HEDDOHON_SETUP=false switches the page off, and /healthz answers 200 with "status": "setup" while the setup waits.

Environment variables

Variable Required Default Meaning
HEDDOHON_SECRET yes, or the first-run setup none Encrypts stored credentials and derives session keys. Minimum 32 characters.
HEDDOHON_SUBSONIC_URL one of, or the first-run setup none Navidrome / Subsonic base URL. HEDDOHON_NAVIDROME_URL is accepted as an alias.
HEDDOHON_JELLYFIN_URL one of, or the first-run setup none Jellyfin base URL.
HEDDOHON_SUBSONIC_LABEL no Navidrome Name shown on the sign-in screen.
HEDDOHON_JELLYFIN_LABEL no Jellyfin Name shown on the sign-in screen.
HEDDOHON_SESSION_HOURS no 720 How long a sign-in lasts, in hours, from 1 upwards. The default is 30 days. It is fixed at sign-in and not extended by activity. Browsers keep a cookie for at most 400 days, so a longer lifetime still ends there.
PUID no the owner of /data, or 1000 From 0.7.0. The user the server runs as in the image. Its entrypoint gives the data directory to that user as the container starts, then drops to it. 99 on Unraid. Not read where the container is started with --user.
PGID no the group of /data, or 1000 From 0.7.0. The group, likewise. 100 on Unraid.
HEDDOHON_DATA_DIR no /data Where the cover cache lives, and the SQLite database unless HEDDOHON_DATABASE_URL is set.
HEDDOHON_DATABASE_URL no none A PostgreSQL server to keep the database on, as postgres://host:5432/database. Unset, the database is SQLite in the data directory. See PostgreSQL.
HEDDOHON_DATABASE_USER no from the URL PostgreSQL user, so it need not be in the URL.
HEDDOHON_DATABASE_PASSWORD no from the URL PostgreSQL password, so it need not be in the URL.
HEDDOHON_DATABASE_PASSWORD_FILE no none A file holding the password, for Docker secrets. Takes precedence over the variable above.
HEDDOHON_DATABASE_SSL no from the URL off, require (encrypted, certificate not checked) or verify-full. Unset, the URL's sslmode applies.
HEDDOHON_DATABASE_IMPORT no true Copy an existing SQLite database into an empty PostgreSQL one on first start.
HEDDOHON_COOKIE_SECURE no auto auto sets Secure when the request arrives over https, when NODE_ENV=production, or when the host is not loopback. The Docker image sets NODE_ENV=production, so there auto is always Secure and a deployment reached over plain http needs false. The cookie is named __Host-heddohon_session wherever it is Secure.
HEDDOHON_UPSTREAM_TIMEOUT_MS no 20000 Give-up time for music-server calls: the wait for headers, then again for a JSON body. A stream has no limit once its headers arrive.
HEDDOHON_COVER_CACHE_MB no 512 Disk budget for cached cover art, in megabytes. 0 switches the cache off. From 0.7.0, left out, it is asked for by the first-run setup and can be changed by an administrator under Settings, Storage.
HEDDOHON_SHARING no true Song links. false hides the share buttons, refuses new links, and stops existing links from opening. Read at startup, so a change takes a restart. Links are kept, and open again if it is set back to true.
HEDDOHON_DOWNLOADS no true The "Download original" link in the player's track details, and the route behind it. false removes both. Signed-in browsers are sent the same files to play them, so this is not copy protection.
HEDDOHON_REMOTE_CONTROL no true The player's Devices button, which pauses, skips, seeks and moves the queue between browsers signed in to the same account, and the routes behind it (/api/remote). Each browser with the player open holds /api/remote/events open as a server-sent events stream; a reverse proxy must not buffer it (nginx: proxy_buffering off, or the X-Accel-Buffering: no header Heddohon sends). The browsers are known to one process, so set false when running more than one behind a load balancer. Listening together uses the same in-memory registry, and is off when this is false. From 0.6.0 an account on the host's music server can join a listen-together session as a member and add tracks to the host's queue.
HEDDOHON_LISTENERS no true From 0.6.0. Listening now: accounts signed in to this Heddohon can see what each other is playing. An account is shown only after it turns on "Show others what I play" under Settings, Account, where it also sets a display name of up to 32 characters and a picture; an account that is not shown still sees the others. While an account that is shown plays, every other signed-in account is sent its name, its picture and the track, and nothing of it is stored: a pause, a closed browser and signing out take it out of the list. false removes the pill, the popup, the section in Settings and the routes under /api/profile. It is sent down the remote control's event stream, so it is off when HEDDOHON_REMOTE_CONTROL is false.
HEDDOHON_LYRICS_LRCLIB no false Also switched by the first-run setup and by an administrator under Settings, Features (from 0.8.0), which save it to setup.json; set here, it locks that switch. Lyrics from LRCLIB for tracks the music server has no synced lyrics for. When true, the server sends the artist, title, album and length of each such track to LRCLIB when its lyrics are opened.
HEDDOHON_LYRICS_LRCLIB_URL no https://lrclib.net Base URL of the LRCLIB instance to ask, for a self-hosted copy. Only read when the above is true.
HEDDOHON_AUTOEQ no false Also switched by the first-run setup and by an administrator under Settings, Features (from 0.8.0), which save it to setup.json; set here, it locks that switch. Headphone corrections from the AutoEq database, searched by name under Settings, Playback, Equaliser. When true, the server fetches the index of profiles once a day, and the correction of each headphone chosen, from the URL below. That host learns this server's address and which headphones were chosen. Importing a ParametricEQ.txt works either way and contacts nothing.
HEDDOHON_AUTOEQ_URL no https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results The results directory of AutoEq to read, for a mirror. Only read when the above is true. The index is kept in HEDDOHON_DATA_DIR/autoeq-index.json.
HEDDOHON_NEW_RELEASES no false Also switched by the first-run setup and by an administrator under Settings, Features (from 0.8.0), which save it to setup.json; set here, it locks that switch. From 0.8.0. Also offered by the first-run setup, which saves it to setup.json; the environment takes precedence. Lets each account turn on a "New releases" shelf on its home page under Settings, Listening history: albums and EPs from the last year, and announced ones, by the 25 artists it played most in the last year, that the library does not have. For an account that turns it on, the server sends the names of those artists (or the MusicBrainz ids the music server keeps for them) to MusicBrainz, one request a second, and holds the answers for a day. The shelf is made as an account turns it on and shown at once after that; the answers and the shelves are kept in new-releases.json in the data directory.
HEDDOHON_MUSICBRAINZ_URL no https://musicbrainz.org The MusicBrainz to ask, for a mirror. Only read when the above is true.
HEDDOHON_DISCORD no false Also switched by the first-run setup and by an administrator under Settings, Features (from 0.8.0), which save it to setup.json; set here, it locks that switch. From 0.6.0. Lets an account paste a Discord webhook address under Settings, Account, and posts each track it plays past half its length, or four minutes, to that channel: the title, artist, album and cover, under the webhook's own name. The cover is uploaded with the post at 256px (JPEG, PNG, WebP or GIF, up to 1 MB). The account's user name is not posted. When true, the server sends those to Discord for each account that links a channel, at most 5 in a burst and then one every 20 seconds. Nothing is sent while the account has "Report playback" off.
HEDDOHON_DISCORD_URL no https://discord.com Where webhook requests are sent. Only read when the above is true.
HEDDOHON_LISTENBRAINZ no false Also switched by the first-run setup and by an administrator under Settings, Features (from 0.8.0), which save it to setup.json; set here, it locks that switch. From 0.6.0. Lets each account paste a ListenBrainz user token of its own under Settings, Account, on Jellyfin or Navidrome. The server then sends ListenBrainz what is playing at the start of a track and a listen when the play counts. The token is kept encrypted and is not shown again. An account on a Navidrome that links ListenBrainz itself is offered that one setting, under Scrobbling, and not this one.
HEDDOHON_LISTENBRAINZ_URL no https://api.listenbrainz.org The ListenBrainz API to send to, for a server of your own. Only read when the above is true.
HEDDOHON_FOLDERS no true The Folders page, which browses the library as it is laid out on disk, and playing a folder. false removes the page, its links and the route behind it, for a library whose folder names are not for its listeners.
HEDDOHON_DEFAULT_THEME no terminal From 0.7.0. The theme an account starts in, and the one the sign-in page and shared links are drawn in: terminal, liquid, paper or phosphor. Each account can change its own under Settings, Appearance. An account whose settings were saved before 0.7.0 with Liquid as its theme is moved to this theme once, at its first page after the update; set liquid to leave every account where it is.
HEDDOHON_SETUP no true From 0.7.0. false switches the first-run setup off: a start without the secret or a music server is then a configuration error, as before 0.7.0.
HEDDOHON_RADIO no true The Radio page, for the internet radio stations Navidrome lists (Jellyfin has none). A station is played through this server, which fetches its stream when a listener plays it. false removes the page and the route.
HEDDOHON_RADIO_PRIVATE no false Allows a station on a private, loopback or link-local address, for a stream served on your own network. Off, such a station is refused. A station on the internet still cannot redirect the server to a private address: only a hop that was itself on one may lead to another.
HEDDOHON_NAVIDROME_PLUGIN_TOKEN no none From 0.6.1. Set, it turns on /api/plugin/navidrome, which the Heddohon plugin for Navidrome posts to: the plays made in other apps go into each account's listening history, and "Import play history" brings in every play Navidrome kept. At least 32 characters, and not the value of HEDDOHON_SECRET: openssl rand -hex 32. The plugin's settings in Navidrome take the same value. Unset, the route answers 404. See The Navidrome plugin.
HEDDOHON_MCP no false Lets each account make a token under Settings, AI, for an AI assistant that speaks the Model Context Protocol, which then searches its library at /api/mcp and, if the token allows it, changes its playlists, favourites and ratings. Unset, the first-run setup and an administrator under Settings, AI, turn it on and off; set, it decides, and Settings does not offer the switch. See AI assistants (MCP).
HEDDOHON_REQUIRE_TWO_FACTOR no false Holds every account to a second factor at sign-in, an authenticator app or a passkey, set up under Settings, Security. An account without one reaches only that page until it has one, and its AI assistant tokens open nothing meanwhile. Unset, each account decides, and an administrator turns it on and off under Settings, Security; set, it decides, and Settings does not offer the switch. From 0.8.0.
HEDDOHON_PROFILES no true Profile pages: People, each account's page with four albums it picks, and their public links. false switches them off, and every route of theirs answers as a page that does not exist; what was saved stays. Unset, an administrator switches them under Settings, Features; set, it decides, and Settings does not offer the switch. From 0.8.0.
HEDDOHON_ANDROID_FINGERPRINTS no none SHA-256 certificate fingerprints, comma-separated, of an Android app you built and signed yourself. The server already vouches for the released app (/.well-known/assetlinks.json), which is what lets it open without an address bar; this adds your own key beside it.
HEDDOHON_LOG_LEVEL no error error, warn, info or debug.
HEDDOHON_LOG_FORMAT no text json emits one object per line instead.
HEDDOHON_LOG_SLOW_MS no 2000 Requests at or above this are logged as request-slow. 0 disables that.
HEDDOHON_LOG_KEEP_DAYS no 7 How many days of log files are kept in HEDDOHON_DATA_DIR/logs. One file a day; older ones are deleted when the server starts and when the date changes. 0 writes no files.
HEDDOHON_APP_NAME no Heddohon Branding on the sign-in screen and sidebar.
HEDDOHON_LOGIN_HINT no none Optional line of text on the sign-in screen.
ORIGIN no, from 0.7.0 none The address Heddohon is opened at, with its scheme and port: http://192.168.1.10:3000 on a LAN, https://music.example.com behind a proxy. A write whose Origin is anything else is refused, sign-in included. From 0.7.0, left out, the address is asked for by the first-run setup and an administrator can add or change addresses under Settings, Storage, This server; on a saved http:// address on the local network the sign-in cookie is sent without Secure. With neither, the server takes the request's Host header and https for its own address, which is right only behind an https proxy that passes Host on; over plain http every sign-in is then refused with a 403.
PORT / HOST no 3000 / 0.0.0.0 Listen address.

GET /healthz reports configuration validity without revealing the upstream URLs, and is wired up as the container's HEALTHCHECK.

Jellyfin Quick Connect

The sign-in screen offers Quick Connect for a Jellyfin server when the server reports it as turned on (Dashboard, General, Quick Connect). It is off by default in Jellyfin. Heddohon asks the server whether it is on at most once a minute, so turning it on or off shows on the sign-in screen within 60 seconds.

The screen shows a 6-digit code. Enter it in Jellyfin under your user settings, Quick Connect, and the Heddohon page signs in within 3 seconds. A code expires after 10 minutes. The sign-in ends at the same stored access token as a password sign-in.

Heddohon allows 100 Quick Connect starts in 15 minutes for the whole deployment, and 20 per client address where addresses identify visitors (see Behind a reverse proxy). Jellyfin approves whichever pending request matches the code a user types, so the total bounds how many codes a mistyped entry could match. Password sign-in is counted separately and is not affected when the Quick Connect limit is reached.

Single sign-on (OpenID Connect)

From 0.8.0. Heddohon signs in through an OpenID Connect provider (Authelia, Authentik, Keycloak, Pocket ID) for an account that has linked its identity there. It still needs the music server's credential, so the link is made once by a person signed in here with their music server password, and a sign-in through the provider then uses the credential stored at that account's last password sign-in.

At the provider, register a client for Heddohon:

Setting Value
Redirect URI https://music.example.com/login/oidc/callback, on each address Heddohon is opened at
Grant type authorization code, with PKCE (S256)
Scope openid
Client authentication client_secret_basic, client_secret_post, or a public client without a secret

Then set HEDDOHON_OIDC_ISSUER to the issuer the provider names in its discovery document (Authentik's ends in a slash, https://auth.example.com/application/o/heddohon/; Keycloak's is https://auth.example.com/realms/<realm>), with HEDDOHON_OIDC_CLIENT_ID, HEDDOHON_OIDC_CLIENT_SECRET and, for the button, HEDDOHON_OIDC_LABEL. Heddohon reads <issuer>/.well-known/openid-configuration at the first sign-in and once an hour after.

For example, with Pocket ID at https://id.example.com: under OIDC Clients, add a client named Heddohon with the callback URL https://music.example.com/login/oidc/callback, leave "Public client" off, and copy the client ID and secret it shows once. Then:

HEDDOHON_OIDC_ISSUER=https://id.example.com
HEDDOHON_OIDC_CLIENT_ID=<the client ID>
HEDDOHON_OIDC_CLIENT_SECRET=<the client secret>
HEDDOHON_OIDC_LABEL=Pocket ID

Pocket ID asks for no consent to a name or an email address, since Heddohon asks for none (below).

How a sign-in through the provider works

  1. "Sign in with" the label sends the browser to the provider's authorization endpoint with scope=openid, a random state, a random nonce, and a PKCE challenge: the SHA-256 of a random verifier (code_challenge_method=S256). The state, the nonce and the verifier stay in a sealed cookie in that browser for 10 minutes.
  2. The person signs in at the provider, which sends the browser back to /login/oidc/callback with a one-time code and the state. A state other than the cookie's is refused there, before the provider is asked anything, so a return sent to another browser does nothing.
  3. Heddohon sends the code, the verifier and its client ID and secret to the provider's token endpoint, server to server. The provider gives back an ID token only to whoever holds the verifier, which never left Heddohon's cookie, so a code seen in transit is of no use to anyone else.
  4. The ID token is checked: its signature against the keys the provider publishes (jwks_uri), the issuer, this client as the audience, the expiry, and the nonce from step 1.
  5. Its sub is the provider's id for the person: a fixed value it never gives to anyone else, not a name or an email address. Heddohon looks up the account that sub was linked to and signs in to it with the music server credential stored at that account's last password sign-in, after asking the music server whether it still takes it.

Only openid is asked for, so the provider sends no name, email address or group. No claim a person can change at the provider is matched to an account: a matching email address or user name does not sign in to anything.

Each account then links its identity under Settings, Security, confirming with its music server password or its second factor, and is sent to the provider and back. From then on "Sign in with" the label on the sign-in page signs in to that account, and an account with a second factor is asked for it afterwards. One identity links to one account, and one account to one identity.

The link is removed by "Unlink", by a password sign-in that stores a new Navidrome password, and when a new Jellyfin user holds the account's name; it is made again from Settings. A sign-in through the provider asks the music server first whether the stored credential still works, and sends the person to the password form where it does not.

Returns from the provider are limited to 30 per client address and 600 for the whole deployment in 15 minutes. SECURITY.md has what is checked.

PostgreSQL

The database is SQLite in the data directory unless HEDDOHON_DATABASE_URL names a PostgreSQL server. PostgreSQL is the only server supported.

Create a database and a user for Heddohon on the server, for example:

CREATE USER heddohon WITH PASSWORD 'choose-one';
CREATE DATABASE heddohon OWNER heddohon;

Then set:

HEDDOHON_DATABASE_URL=postgres://db.lan:5432/heddohon
HEDDOHON_DATABASE_USER=heddohon
HEDDOHON_DATABASE_PASSWORD=choose-one

Heddohon creates a heddohon schema and its tables in it on first start, so it needs to be allowed to create a schema in that database (the owner is). Keeping to its own schema means it can share a database with other applications without touching their tables. When the connection is not encrypted, a database-unencrypted line is logged at warn on start-up.

Moving from SQLite. On the first start against an empty database, if heddohon.db is in HEDDOHON_DATA_DIR, it is copied across: accounts, settings, saved queues, share links, linked Discord channels and ListenBrainz tokens, the profiles of Listening now (display name, picture and whether the account is shown), and listening history (with each play's track details). Sessions and sign-in counters are not, so everyone signs in once. Stored music-server credentials are copied sealed, so HEDDOHON_SECRET must stay the same. The copy is one transaction and the SQLite file is only read; a sqlite_import row in the meta table records that it has run, and it does not run again. The line database-imported is logged at warn with the counts. Set HEDDOHON_DATABASE_IMPORT=false to start empty instead.

What changes.

  • The data directory is still needed, for the cover cache.
  • Sessions and settings are read on every request. Against PostgreSQL each is remembered in memory for 5 seconds, so a page of covers does not make a database round trip per image. Signing out, a changed credential and saved settings clear the memory at once in the process that made the change.
  • One Heddohon process per database is the supported setup. A second process on the same database would see a sign-out made through the first up to 5 seconds late.
  • /healthz checks the database and answers 503 with database-unavailable when it does not reply within 3 seconds, which turns the container's health check red. Requests fail with a plain error while the database is down, and recover without a restart when it is back.
  • Queries time out after 8 seconds on the client and 10 on the server.

Cover cache

Cover art is written to $HEDDOHON_DATA_DIR/covers the first time it is fetched, and served from there afterwards. Files are named for the backend, the music server's own cover id and the size requested, subsonic-f4a1b2c3-256.jpg, so the directory can be read and pruned with ordinary tools.

Measured against a music server that takes 180ms to render a cover: a cold request takes 190ms and a cached one 4ms, and a 62-cover page that has never been opened on this browser goes from 1365ms to 776ms.

Once the directory passes HEDDOHON_COVER_CACHE_MB, the least recently used files are deleted until it is back under. The check runs after every HEDDOHON_COVER_CACHE_MB / 16 megabytes written, so the directory can sit that much above the budget between sweeps. Covers over 8MB are served but not stored.

Settings shows what the cache holds, who it reaches, and has a button that empties it. Anyone signed in can clear it: the files are the music server's artwork rather than anything belonging to one account, and one copy is shared by everyone signed in to that server. Nothing is lost by clearing it, and the next request for each cover fetches it again.

The music server's own administrator flag is read when the page loads, from getUser on a Subsonic server and from the user record on Jellyfin, and is used to say so where the account is not an administrator. It does not decide whether the button is there. A Subsonic server need not implement getUser, and a server that does may refuse it; gating on an answer that can be absent would lock an operator out of a button on their own instance, to protect against an action whose whole cost is that some covers are fetched once more.

From 0.6.0, an account the music server lists as an administrator also has Cache every cover there. The server reads every album, artist and playlist and stores each cover at the sizes the pages draw: 96, 384, 512 and 1024 pixels for an album, 96 and 384 for an artist or a playlist, in that order of size across the whole library. It asks the music server for 4 covers at a time, carries on with the page closed, skips covers already held, and stops at HEDDOHON_COVER_CACHE_MB rather than let the sweep delete what it stored first. A track whose artwork is its own (Navidrome gives those an id per track) is cached when it is played. This one is gated on the administrator flag, and a server that does not answer is refused: it has the music server render every cover in the library. On Jellyfin the covers are kept for the administrator's account only, since the cache key carries the viewer there. One fill runs at a time, and a restart ends it; starting again skips what is held.

Keep it filled, a switch under it, has the server repeat the fill once a day and after a restart, so covers added since are cached before anyone opens them. A repeat over a library that is all held costs the reads of the library and no covers. It uses the stored sign-in of the administrator who switched it on, goes on after that account signs out, and switches itself off if the music server stops listing the account as an administrator. A music server that is not answering is asked again within the hour. After the account's password changes on the music server, the repeats fail (cover-fill-failed), and the switch is off once the account signs in with the new password: it is switched on again by whoever holds the account then. The account it runs as can switch it off whether or not it is still an administrator.

Colour schemes from files

Terminal comes with ten colour schemes. An administrator can add more without touching the code: a JSON file in $HEDDOHON_DATA_DIR/themes/ is offered under Settings → Appearance → Colour scheme, and on the command line as scheme <name>, for every account. The file's name, less .json, is the scheme's name there: lower-case letters, digits and hyphens, up to 40, and not one of the built-in ten.

{
  "heddohon": 1,
  "name": "Rosé Pine",
  "ground": "#191724",
  "raised": "#26233a",
  "foreground": "#e0def4",
  "accents": {
    "green": "#9ccfd8",
    "amber": "#f6c177",
    "cyan": "#9ccfd8",
    "violet": "#c4a7e7",
    "rose": "#ebbcba",
    "white": "#e0def4"
  },
  "positive": "#9ccfd8",
  "danger": "#eb6f92"
}

heddohon is the version of the format, 1. name is shown in Settings. Every colour is written #rrggbb. The first five keys are needed:

Key What it is
ground The page behind everything. It has to be dark: Terminal is drawn light on dark.
raised One step up from the ground: hairlines, and raised controls. The panes are a third of the way from the ground to it.
foreground The brightest text: titles and what is selected. It has to hold 7:1 on the pane.
text Body text. Made from foreground if left out.
accents The colours offered under Accent: green, amber, cyan, violet, rose and white. Any left out take a stock colour.
positive, danger Success and errors.
sunken, hover, active The ground's step down, and a control under the pointer and pressed. Made from the others if left out.

The rest are made the way the built-in schemes were: the muted and faint text are taken toward the pane until they hold at least 5.3:1 and 4.6:1, an accent is lightened until it holds 5:1 under the dark label of a filled button, and positive and danger until they hold 4.5:1 on the pane. A scheme given Nord's published colours comes out within a step of the built-in Nord.

Files are read as the server starts, so restart it after adding or changing one. A file that cannot be used is left out and named in the log at warn, with the reason:

2026-10-21T12:00:00.000Z warn  scheme-refused file=pale.json detail="\"ground\" is too light: Terminal is drawn light on dark"

An account on a scheme whose file is taken away goes back to Terminal's own.

A file is colours and nothing else, so it cannot load an address or change the page beyond its colours: the stylesheet is written by Heddohon from the checked values. This is the first part of a theme engine (#182); themes that change the other three themes, or more than colour, are not covered yet.

Discord and ListenBrainz

From 0.6.0. Both are off until the operator turns them on, and each account links its own under Settings, Account, "Your plays, elsewhere". Nothing is sent while the account has "Report playback" off.

A Discord channel

A play as it is posted: the webhook's name, the title, the artist, the album and the cover

A drawing of a post, not a capture from Discord.

  1. Set HEDDOHON_DISCORD=true and restart.
  2. In Discord, open the channel's settings: Edit channel, Integrations, Webhooks, New webhook. The name and the picture given to it there are what the posts appear under. Copy its address.
  3. In Heddohon, paste the address under Settings, Account, and press Link.

Each track played past half its length, or four minutes, is then posted to the channel: the title, the artist, the album and the cover. The cover is uploaded with the post at 256px, as a JPEG, PNG, WebP or GIF of up to 1 MB. A post carries neither the address of this server nor the account's user name. Discord's servers see the address the request comes from.

Posts are limited to 5 in a row and then one every 20 seconds for an account. A webhook deleted on Discord is unlinked by the first play that finds it gone. Unlinking does not remove posts already in the channel.

ListenBrainz

  1. Set HEDDOHON_LISTENBRAINZ=true and restart.
  2. On ListenBrainz, copy the user token from your settings page.
  3. In Heddohon, paste it under Settings, Account, and press Link.

The server sends what is playing at the start of a track, and a listen when the play counts. Each account links a token of its own.

Navidrome links ListenBrainz itself, under Scrobbling, and sends the plays from every player. An account on such a server is offered that setting alone: the field here is for Jellyfin, for a Navidrome with ListenBrainz turned off, and for other Subsonic servers. An account that linked a token here before keeps the row, to unlink it.

A webhook address and a token are kept encrypted, are not shown again, and are removed when the account's password changes on the music server.

The Navidrome plugin

From 0.6.1. Navidrome tells a plugin of each track a user finishes, whichever app played it. With the Heddohon plugin in Navidrome:

  • a play in Symfonium, DSub or Navidrome's own player is noted in Recently played and counted in Your listening, beside the plays made in Heddohon. A play made in Heddohon is noted once;
  • "Import play history", under Settings, Listening history, brings in every play Navidrome kept, each at its date. Without the plugin it has one play per track, at the date it was last played;
  • the Devices button in the player lists the other apps signed in as you that are playing, each with its track, and "Continue here" plays that track in this browser from where the app has got to, in place of the queue. The app goes on playing: Navidrome has no way to pause a client. Few apps report their position so far. For one that only says a track started, which is most, the dialog says so, and the time is where the track would be had it played without a pause. It needs HEDDOHON_REMOTE_CONTROL;
  • Listening now can include what an account plays in other apps. It is off for every account until it turns on "Include what I play in other apps" under Settings, Account, beside "Show others what I play". With both on, the others see it with no browser of that account open. An app that does not say when it pauses is shown until its track would have ended.

It needs Navidrome 0.64 or later.

  1. Choose a token of at least 32 characters (openssl rand -hex 32), set it as HEDDOHON_NAVIDROME_PLUGIN_TOKEN and restart Heddohon. It must not be the value of HEDDOHON_SECRET.
  2. Put heddohon.ndp in Navidrome's plugins folder (plugins in its data folder) and restart Navidrome. The plugin has a repository of its own, heddohon-navidrome-plugin: the file is attached to its releases, and build.sh there builds it.
  3. In Navidrome, under Plugins, open Heddohon and fill in the address Navidrome reaches Heddohon at and the token. Where that address is not the one Heddohon is opened at in a browser, also fill in "Heddohon's public address" with the value of ORIGIN.
  4. Choose the users it covers, or all of them, and enable it.

Settings, Listening history, then shows "Plays from other apps", which each account can turn off for itself, and says when the plugin has not been heard from in the last minute. A user who has never signed in to Heddohon has no history there, and its plays are dropped. A play made while Heddohon is down is kept by Navidrome and sent again, at its original time.

Navidrome turns a plugin off when its file changes: after replacing heddohon.ndp with a newer one, enable it again under Plugins.

The plugin sends Heddohon a user name, a track id and a time for each play, and for what a client is playing now the track, whether it plays or is paused, the position and the client's name. It contacts nothing else, and what an app is playing is held in memory and not stored. What the token allows is in SECURITY.md, and the plugin's own notes, with what its log lines mean and the messages it sends, are in its repository.

AI assistants (MCP)

An AI assistant that speaks the Model Context Protocol (MCP), such as Claude Code, can search an account's library and read its favourites, playlists, history and listening statistics, and, with a token that allows it, make, rename and delete playlists, change their entries, and set favourites and ratings. It reaches the music server as that account, and what it reads goes to whoever runs its model.

It is off until one of these turns it on:

  • HEDDOHON_MCP=true in the environment, which then decides it;
  • "AI assistants (MCP)" on the first-run setup page;
  • an administrator of the music server, under Settings, AI, "Allow AI assistants (MCP)". Saved in setup.json and taken at once. Not offered where the environment sets the variable.

Each account then makes its own tokens under Settings, AI: a name, and whether the token may change the library. The token is shown once. For Claude Code, the page also shows the command that adds it:

claude mcp add --transport http heddohon https://music.example.com/api/mcp \
  --header "Authorization: Bearer hhmcp_..."

Another client takes the address, /api/mcp on the address Heddohon is opened at, and the header Authorization: Bearer followed by the token. The endpoint is the Streamable HTTP transport without a stream: each POST is one JSON-RPC message and gets one JSON answer.

A token has no expiry. It ends when the account withdraws it under Settings, and with every session of the account: a sign-in with a changed password, or the music server refusing the stored credential. Turned off, the endpoint answers 404 and the tokens already made stay listed, to be withdrawn. What a token allows is in SECURITY.md.

Logging

The process writes to stdout and stderr. Whatever runs the container decides where that goes, which is what docker logs, journald and every process supervisor expect. Warnings and errors go to stderr, the rest to stdout.

The same lines are kept in files as well, one a day: HEDDOHON_DATA_DIR/logs/heddohon-YYYY-MM-DD.log, by the UTC date the lines carry. HEDDOHON_LOG_KEEP_DAYS is how many days of them are kept, 7 unless set: files dated further back are deleted when the server starts and each time the date changes. 0 writes no files and deletes none. The files survive an update that replaces the container, which docker logs does not. A logs directory that cannot be written turns the files off until the next start and is reported once as log-file-failed; the server runs on.

The default is the lightest setting there is: a server that is working writes nothing at all. Raise it for more.

Set this And you also get
error (default) Failures only. A quiet log is a working server.
warn Refused cross-origin writes, rejected and throttled sign-ins, upstream timeouts, requests over HEDDOHON_LOG_SLOW_MS.
info What the server started with, who signed in, cover cache clears and sweeps.
debug A line per request, a line per call to the music server with its timing, and cover cache hits and misses.

Each line is a timestamp, a level, an event name, then key=value pairs:

2026-09-14T23:39:34.873Z info  started build=1789429163439 node=22.22.2 logLevel=info logFiles=7d data=/data covers=512MB sessionHours=720 upstreams="subsonic=10.0.0.10:4533"
2026-09-14T23:39:35.277Z info  signed-in username=alice backend=subsonic address=10.0.0.5
2026-09-14T23:41:02.113Z warn  sign-in-rejected username=bob backend=subsonic address=10.0.0.9 kind=auth

The events at each level, by name:

Level Events
error request at 5xx or thrown, unhandled with a stack, config-invalid, sign-in-failed, quick-connect-failed on a fault in this server
warn request-slow, second-factor-rejected (a wrong code, passkey or password for an account at sign-in or for a change, with the method), second-factor-throttled (five wrong ones for an account in 15 minutes; with the retryAfter), recovery-code-used (with how many are left), recovery-codes-made, second-factor-added and second-factor-removed (with the factor and how many other sessions ended), passkey-rejected (with the step and why), passkey-throttled, passkey-credential-stale (a passkey sign-in whose stored music server credential no longer works), oidc-linked and oidc-unlinked (an account linked or unlinked its identity at the OpenID Connect provider), oidc-rejected (a return from the provider refused, with the purpose, the step and why), oidc-throttled, oidc-failed (the provider could not be read, or its token endpoint answered 500 or more), oidc-credential-stale (a sign-in through the provider whose stored music server credential no longer works), two-factor-cleared (an administrator removed an account's factors, with by), two-factor-required-switched (with by and on), features-switched (an administrator saved Settings, Features; with by and the features left on), new-releases-failed (MusicBrainz did not answer for an artist of the "New releases" shelf; the shelf is made without it), library-report-failed (the music server did not list the library for an administrator's report), radio-failed (with the station's id and the kind: refused, unreachable or not_audio), log-file-failed, cross-origin-blocked, plugin-refused (a request to the Navidrome plugin's route without its token), mcp-refused (a request to /api/mcp without a live token), mcp-switched (an administrator turned AI assistants on or off under Settings, with who and on), sign-in-rejected, sign-in-throttled, quick-connect-throttled, quick-connect-failed, sessions-destroyed, upstream-timeout (with the phase when it was the body), upstream-oversized, upstream-unreachable, database-connection-lost, section-failed, cover-write-failed, cover-fill-failed, integration-failed (with the kind, discord or listenbrainz, the status, and whether it was unlinked), history-write-failed, history-import-failed, transcode-read-failed, autoeq-failed (with what: the index or a profile), autoeq-store-failed, scrobbler-failed (with the step: status, a link, an unlink, or a refused Last.fm return)
info started, sign-in-step (a password, Quick Connect or single sign-on sign-in accepted, waiting for the account's second factor), passkey-added, passkey-removed, daily-mixes-made (an account's mixes of the day, with how many and their songs), library-report-made (with the tracks and the time it took), mcp-token-made (with writes), mcp-token-withdrawn (with how many), autoeq-index (the status, 304 for an unchanged index, and how many profiles it lists), signed-in (with method=quick-connect for a Quick Connect sign-in), quick-connect-started, scrobbler-linked, scrobbler-unlinked, integration-linked, integration-unlinked, cover-cache-cleared, cover-cache-swept, cover-fill-kept (who has the fill repeated daily, or nobody), cover-fill-started, cover-fill-finished (with the state it ended in, and how many covers it stored and failed), history-imported (with how many songs the music server had played and how many plays were added; with from=scrobbles, how many plays the Navidrome plugin sent)
debug request (one per request, with its path, status and duration), daily-mix-part-failed (one read for a daily mix the music server did not answer, which leaves that part out), mcp-call (one per tool an AI assistant calls, with the tool, its time and any error), upstream (one per music-server call, with its time), cover-hit, cover-miss, cover-stored, unauthenticated, plugin-plays (how many plays the Navidrome plugin sent in a request and how many went into a history), plugin-play-dropped (with why: the account's credential was refused, or it cannot read the song)

Working out why something is slow

HEDDOHON_LOG_LEVEL=debug times every call to the music server separately from the request around it, and says whether each cover came from the cache or from upstream. Every line written while handling one request carries the same req id, so they can be read together:

debug upstream path=/rest/getAlbum.view status=200 ms=18 user=alice req=99dabdad
debug upstream path=/rest/getSimilarSongs2.view status=200 ms=35 user=alice req=99dabdad
debug request method=GET path=/albums/al1 status=200 ms=301 user=alice req=99dabdad

Upstream lines for one request can appear after its request line. The album page streams its shelves, so the response begins before those calls finish.

Turn it off again afterwards. Measured against this build, warmed: 895 requests per second for a cached cover at the default level and 760 at debug, and 189 per second for a library page against 169. A library page opens dozens of covers at once, so that 15% is paid dozens of times over. Nothing but the id is carried per request at the default level, and the request context that ties those lines together is not established at all.

At info the log records usernames and the client address on sign-in attempts, and request paths only for a request that failed or was slow. It never records a password, a session token, or an upstream query string: a Subsonic request carries the account's token and salt as query parameters, so upstream lines log the path alone.

Recommendations

An album page carries two shelves under its track list. The first, "More from ", is the rest of that artist's catalogue, read straight out of the library, and needs nothing configured. The second, "You might like", and the one under an artist, are whatever the music server answers when asked what is similar, so what they need is configured there rather than here. The artist's own records are excluded from the suggestions, since they are in the shelf above.

Navidrome and other Subsonic servers. Heddohon reads similarArtist from getArtistInfo2 on an artist page, and getSimilarSongs2 on an album page, grouping the tracks that come back into the albums they belong to. Navidrome fills both from Last.fm, and Last.fm needs an API key that Navidrome does not ship with. Without ND_LASTFM_APIKEY and ND_LASTFM_SECRET set, the first returns nothing and the second returns the artist's own tracks, which the shelf leaves out. A key is free from https://www.last.fm/api/account/create. Artist biographies come from the same place, so a Navidrome with no key shows no biography either.

From 0.6.0, an album's shelf that this leaves short of its eight is filled from the library: random tracks in the album's genre, grouped into their albums, or from the two years either side of its own where it has no genre. An artist's shelf is not filled this way.

Jellyfin. /Artists/{id}/Similar and /Albums/{id}/Similar are computed by Jellyfin from its own metadata (genres, tags, people). No external service and no extra configuration.

Nothing else is computed by Heddohon. An empty shelf means the server returned nothing, and the server log records the call if it failed outright.