Repository navigation
Configuration
| 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. |
docker compose up -d
docker compose logs heddohonFrom 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 -ddocker 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:latestHeddohon 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.
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.
npm install
npm run dev # development
npm run build && npm start # productionOn Node 22 or later.
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.
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.
| 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.
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.
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).
- "Sign in with" the label sends the browser to the provider's
authorization endpoint with
scope=openid, a randomstate, a randomnonce, 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. - The person signs in at the provider, which sends the browser back to
/login/oidc/callbackwith a one-timecodeand thestate. Astateother than the cookie's is refused there, before the provider is asked anything, so a return sent to another browser does nothing. - 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.
- 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. - Its
subis 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 thatsubwas 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.
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.
-
/healthzchecks the database and answers503withdatabase-unavailablewhen 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 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.
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.
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 drawing of a post, not a capture from Discord.
- Set
HEDDOHON_DISCORD=trueand restart. - 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.
- 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.
- Set
HEDDOHON_LISTENBRAINZ=trueand restart. - On ListenBrainz, copy the user token from your settings page.
- 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.
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.
- Choose a token of at least 32 characters (
openssl rand -hex 32), set it asHEDDOHON_NAVIDROME_PLUGIN_TOKENand restart Heddohon. It must not be the value ofHEDDOHON_SECRET. - Put
heddohon.ndpin Navidrome's plugins folder (pluginsin its data folder) and restart Navidrome. The plugin has a repository of its own, heddohon-navidrome-plugin: the file is attached to its releases, andbuild.shthere builds it. - 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. - 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.
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=truein 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.jsonand 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.
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) |
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.
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.