- Go 90.8%
- templ 8%
- Shell 0.5%
- CSS 0.4%
- HTML 0.1%
- Other 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
twitch_count and youtube_count arrive as numbers too - v1.3.1 fixed parent_achievements_count, prod then failed on the next field in kind. Re-typed in the client's overlay, where v1.3.0 already did the same for reviews_text_count. Closes #41 |
||
| .builds | ||
| .forgejo/workflows | ||
| cmd | ||
| internal | ||
| pkg/heimdarr | ||
| scripts | ||
| .air.toml | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitbump.toml | ||
| .gitignore | ||
| .golangci.toml | ||
| .templui.json | ||
| .tokeignore | ||
| AGENTS.md | ||
| compose.yaml | ||
| Dockerfile | ||
| fnox.toml | ||
| go.mod | ||
| go.sum | ||
| go.tool.mod | ||
| go.tool.sum | ||
| heimdarr.toml | ||
| hk.pkl | ||
| LICENSE | ||
| mise.lock | ||
| mise.toml | ||
| openapi.json | ||
| PLAN.md | ||
| PLAN_DOCS.md | ||
| README.md | ||
heimdarr
A self-hosted tracker for films, TV shows (reviewed per season), video games and books, with one instance-wide activity feed and a JSON API that third parties can build importers and clients against.
One Go binary, one SQLite file, server-rendered HTML.
- Every account sees every other account's activity. That is the point of the app: a small instance where you know the other people on it.
- Titles are shared instance-wide. One row for The Matrix, not one per person. Reviews, ratings and list entries are per-account rows pointing at it.
- Nothing is reachable without a session. Browsers without one get the login
page; API requests without a token get
401. - No hotlinked images. Posters and avatars are downloaded and served by heimdarr, so no viewer's IP reaches TMDB, RAWG, OpenLibrary, the National Library or your IdP.
- It installs. Add it to a home screen and it runs in its own window under your instance's name. It is not an offline app — your library lives on your server — but it starts like one, and says so plainly when it cannot reach it. Installing needs HTTPS, which is the one thing a reverse proxy has to give it.
Running it
heimdarr signs people in one of two ways, and you pick by whether you set
OIDC_ISSUER:
- With one — Pocket ID, or any provider with an OIDC discovery document. Accounts come from the IdP, and the first person to log in becomes the administrator.
- Without one — a username and password. The first account is created at
/setupwhen you open the instance, and it is the administrator; it can add everybody else from Settings → Accounts.
The two are not mixed: an account has one identity. If you have an IdP, use it;
if you would rather not run a second service to look at your own film list,
leave OIDC_ISSUER unset.
With Docker Compose, against an IdP
services:
heimdarr:
image: src.timharek.no/tim/heimdarr:latest
ports:
- "8080:8080"
environment:
BASE_URL: https://films.example
OIDC_ISSUER: https://id.example
OIDC_CLIENT_ID: heimdarr
OIDC_CLIENT_SECRET: ... # from your IdP
# Optional: an IdP group that maps to admin on every login.
OIDC_ADMIN_GROUP: admins
# Optional: an escape hatch if the first account was a mistake.
ADMIN_EMAIL: you@example.com
# Optional metadata providers. Without them the matching kind is not
# searchable — except books, which the National Library's catalogue
# answers for with no key at all. Everything else still works.
TMDB_API_KEY: ...
RAWG_API_KEY: ...
# Optional: a Jellyfin server to take suggestions from. Read-only, and
# heimdarr never writes a list row from it on its own.
JELLYFIN_URL: https://jellyfin.example
JELLYFIN_API_KEY: ...
volumes:
- heimdarr-data:/data
restart: unless-stopped
volumes:
heimdarr-data:
Leave OIDC_* out and the same compose file runs on passwords; then the first
page you open is /setup.
Run exactly one replica. SQLite is single-writer, migrations run on boot, and
the app opens a single database connection on purpose (see Notes below). Two
containers on one /data will corrupt each other.
Registering the app with Pocket ID
- In Pocket ID, add an OIDC client. Name it whatever you like.
- Set the redirect URI to
{BASE_URL}/auth/oidc/callback— exactly, including the scheme. heimdarr derives it fromBASE_URLand never configures it separately, so a mismatch means login fails with an unhelpful provider error. - Copy the client id and secret into
OIDC_CLIENT_IDandOIDC_CLIENT_SECRET. - Set
OIDC_ISSUERto the issuer URL the discovery document advertises. For Pocket ID that is the instance root, e.g.https://id.example.
The first account to log in becomes admin. If that account was a mistake, set
ADMIN_EMAIL to yours: it wins on every login.
Your display name comes from the IdP's display_name claim, and it is what
the instance shows everywhere instead of your full name. Pocket ID sends it, so
an account provisioned from the IdP starts with the short name you chose there;
editing it at Settings → Profile makes it yours, and a later login will not
overwrite it. A provider that sends no display_name starts you on
preferred_username, and then on your username.
What BASE_URL must be
The public URL a browser sees — the one behind your reverse proxy, with the scheme.
It is what the OIDC redirect is derived from, what the Atom feeds link to, and what
decides whether cookies carry Secure (an https:// value turns it on). If it is
not what the browser actually uses, login will fail.
Put TLS in front. heimdarr does not terminate it.
From a release tarball
tar xzf heimdarr-v0.1.0-linux-arm64.tar.gz
cd heimdarr-v0.1.0
HEIMDARR_BASE_URL=https://films.example \
HEIMDARR_OIDC_ISSUER=https://id.example \
HEIMDARR_OIDC_CLIENT_ID=heimdarr \
OIDC_CLIENT_SECRET=... \
./heimdarr serve --dir=/data
Or, with no IdP at all:
HEIMDARR_BASE_URL=http://localhost:8080 ./heimdarr serve --dir=./data
# open http://localhost:8080/setup
Every heimdarr.toml key has an HEIMDARR_<KEY> environment equivalent, and
serve takes --listen and --dir on top of both. Secrets have no file
equivalent: they come from the environment. The compose examples above use
environment variables throughout, which is the simplest thing that works; a
heimdarr.toml is for the keys you would rather not repeat in ten places.
The API
A JSON API lives at /api/v1, with an OpenAPI 3.1 document at
/api/v1/openapi.json and browsable docs at /api/v1/docs. Generate a client
from the document; mise run openapi dumps it into the repository so a change to
a schema shows up in a diff.
GET /api/v1/health # unauthenticated; the Docker healthcheck
GET /api/v1/me
GET /api/v1/search?q=&kind=&limit= # merged local + remote
GET /api/v1/entities?kind=&ext= # resolve by provider id
GET /api/v1/entities/{id}
POST /api/v1/entities # upsert from a provider id (idempotent)
POST /api/v1/entities/{id}/refresh
GET /api/v1/entities/{id}/reviews
POST /api/v1/entities/{id}/reviews # 1-5 stars + comment + spoiler + date
DELETE /api/v1/reviews/{id}
GET /api/v1/entities/{id}/comments # remarks that are not reviews
POST /api/v1/entities/{id}/comments
PUT /api/v1/comments/{id} # your own only
DELETE /api/v1/comments/{id}
GET /api/v1/entities/{id}/items # who wants/has this
PUT /api/v1/entities/{id}/item # status + dates + favorite; no progress field
DELETE /api/v1/entities/{id}/item
GET /api/v1/users /api/v1/users/{username}
GET /api/v1/users/{username}/items?status=&favorite=
GET /api/v1/users/{username}/reviews
GET /api/v1/feed?since=&limit= # keyset paging
GET /api/v1/export # your own data as JSON
POST /api/v1/import # the same shape; it round-trips
Resolve-by-ext is the contract that makes third-party importers possible.
A provider identity is kind + ext — movie + tmdb:603, game + rawg:3498,
and for a book either ol:OL825W or nb:5dfbb7f8…. Posting one to
/api/v1/entities is idempotent: the same pair always resolves to the same row,
with the snapshot refreshed. An importer
needs to know nothing about how heimdarr stores anything.
Tokens
An interactive login cannot be scripted, so a machine authenticates with a
personal access token: create one at /settings, send it as Authorization: Bearer <token>.
The plaintext is shown once; the row keeps a hash.
Feed readers cannot set headers, so /feed.atom and /u/{username}.atom also
accept ?token=<token> — on reads only. Treat that URL as a secret.
The command line
The same binary that serves the app is a client of it. heimdarr serve is the
server; every other command talks to an instance over the API above, with no
database handle and no knowledge of the schema — so anything the command line
can do, a third party can do too. That is the point of it: a command that wanted
something the API does not offer would be a hole in the API.
Log in once with heimdarr instance add. It asks for the instance URL and a
personal access token from /settings, checks the token works, and stores the URL
in ~/.config/heimdarr/config and the token in your OS keyring — never in the
file:
heimdarr instance add https://films.example
After that, every command just works:
heimdarr whoami
heimdarr search dune --kind book # prints "book ol:OL825W Dune (1965)"
heimdarr track book ol:OL825W --status done
heimdarr review book ol:OL825W --rating 5 --comment "Worth the wait."
heimdarr comment game rawg:3498 --body "No Switch 2 yet, but I want this."
heimdarr favorite movie tmdb:603 --status done
heimdarr list --status done
heimdarr list --favorite
heimdarr list --reviews
heimdarr feed --limit 10
heimdarr export > mine.json
A title is named by its kind and its provider id rather than by an id from this
instance's database, which is what search prints and what makes the same
command work against any instance.
--json
Every command that prints anything takes --json and answers with the API's own
document — the same keys, the same envelopes — so a script never has to parse a
sentence that was written to be read:
heimdarr list --status done --json | jq -r '.items[].entity.title'
heimdarr list --reviews --json | jq -r '.reviews[] | "\(.rating)/5 \(.title)"'
heimdarr feed --limit 10 --json | jq '.activities[] | {actor, verb}'
heimdarr instance list --json # the token is never in it, only has_token
heimdarr track movie tmdb:603 --status done --json
A write that the API answers with no body — track, review, favorite —
prints the row it just made, so nothing has to be read back to see what landed.
An empty list is [] and not null, exactly as in the API. heimdarr export is
that document already and needs no flag.
Several instances, and machines without a keyring
Give each one a short name and pick between them with --instance:
heimdarr instance add https://films.example --name films
heimdarr instance add https://books.example --name books
heimdarr instance list # * marks the one used when you do not say
heimdarr feed --instance books
| Command | What it does |
|---|---|
heimdarr instance list |
The instances set up here, and where each token stands |
heimdarr instance add [url] |
Add or update one; prompts for whatever you leave out |
heimdarr instance get [ref] |
One instance, its account and its config file path |
heimdarr instance forget <ref> |
Drop it here and delete its token; the instance is untouched |
The first instance added is the default, and --default moves it. A reference is
either the shorthand or the URL.
On a server or in CI there is no keyring and no terminal, so pass the credential
instead — --url and --token, or HEIMDARR_URL and HEIMDARR_TOKEN, which are
what those two flags default to:
export HEIMDARR_URL=https://films.example
export HEIMDARR_TOKEN=...
heimdarr feed
Both win over anything configured, so one command can be sent elsewhere without
disturbing the setup. HEIMDARR_CONFIG_DIR moves the config file and
--config-dir does it for one run; neither is the server's --config, which is
heimdarr.toml.
Importing from another tracker
From the browser: Settings → Import. Pick where the file came from, choose it, and heimdarr reads it in the background — the page follows along and says what landed and what it could not place. Closing the tab does not stop it; the result is on that page when you come back.
From a terminal, the same readers against the same library:
heimdarr import letterboxd --file letterboxd-tim-2026.zip
heimdarr import trakt --file trakt.json
heimdarr import watcharr --file watcharr-export.json
heimdarr import heimdarr --file mine.json # from another instance
Every import is forgiving by design: a row it cannot place is counted and
explained — on stderr from the CLI, in the document with --json, on the
settings page from an upload — rather than aborting a job somebody has already
waited on. Nothing is ever replaced: an
import adds what its file says and is not a statement about what it leaves out.
- Letterboxd takes the ZIP as downloaded, or a single CSV out of it. It reads
watched,ratings,diary,reviewsandwatchlist, and ignores the rest. Letterboxd puts no provider id in its export, so every film is resolved by searching for its title and year — which means a large export takes a while and makes one TMDB request per film. A film whose year matches nothing is skipped rather than guessed at: filing Dune (1984) as Dune (2021) is worse than saying it could not tell. - Trakt has no export file of its own, so this reads its API's JSON as saved — a data request, or a dump from any of the usual tools. A bare array from one endpoint works as well as the full object. Every Trakt row carries a TMDB id, so nothing is searched for and nothing is guessed.
- Watcharr reads its JSON export.
- heimdarr takes what
heimdarr exportwrote, from this instance or another.
Jellyfin
Set JELLYFIN_URL and JELLYFIN_API_KEY and an administrator points each
account here at a user on the server, from one card in Settings. heimdarr then
reads what that Jellyfin user has played to the end — every film, and every
season with no episodes left in it — and offers it on that person's feed:
Jellyfin saw you finish this. Done?
The card is the administrator's because the picker names everybody on the media server: handing that list to every account would leak the whole of Jellyfin's user directory into heimdarr to make one link.
It is a suggestion and never a write. What played to the end on a media server is not the same as what somebody watched: a season can run out while the room is empty, and a housemate's profile is not theirs. So heimdarr asks, and a "no" is remembered — a dismissed suggestion does not come back.
The connection is read-only in both directions that matter. heimdarr never writes to Jellyfin, and it never asks anybody for a Jellyfin password: the API key is the operator's, and the setting is only which account on the other side belongs to whom. A title Jellyfin has not matched against TMDB contributes nothing, because without that id there is no title to suggest.
Backing up
The whole instance is one SQLite file under /data, plus the downloaded images
in /data/media.
- Live: heimdarr runs
VACUUM INTO 'backups/heimdarr-<timestamp>.db'daily, which is safe while it is serving. - Whole: stop the container and copy
/data. That is the complete instance.
/api/v1/export gives each person their own reviews and lists as JSON, and
POST /api/v1/import takes the same document back. An app holding somebody's
reviews needs an exit from day one.
Notes
- One replica, one connection. heimdarr opens SQLite with
SetMaxOpenConns(1)and WAL. Serialising access at instance scale removesSQLITE_BUSYentirely instead of tuning around it, but it also means the app is built for a household, not a crowd. - Ratings are whole stars, 1–5, permanently. Half stars are not a schema limitation; they are a decision, and they are not coming.
- No progress percentage. "In progress" is a status. Books and games stay there for weeks, which is why a percentage was never the signal.
- Migrations run on boot, from the gorm models, a short list of raw
CREATE INDEX IF NOT EXISTSstatements, and a list of idempotent backfills for the rows a new column needs. There is no version table and no.sqldirectory.AutoMigrategives an existing row the tag's default, so a new column that should hold anything else needs a backfill —internal/db/migrate_test.gobuilds the old schema and asserts what a deployed database ends up with.
Development
Requires mise, which installs everything else: Go, golangci-lint, hk, pkl and fnox.
mise install # Go, golangci-lint, hk, pkl, fnox, and the Go tool modules
mise run build # build/heimdarr
mise run test
mise run check:ci # what CI runs: tests + fmt + vet + lint on every file
mise run generate # templ + Tailwind, both of which are committed
mise run dev # rebuild templates and CSS on save, server reloads
mise run smoke # the built binary against a throwaway data directory
Generated code — *_templ.go and styles.css — is committed, so a checkout
builds with go alone and a release never fails on a tool download. mise run build therefore does not depend on mise run generate.
pkg/heimdarr is the domain and may not import the web or metadata layers;
internal/web/view may not import the metadata clients. depguard enforces
both. See AGENTS.md for the layering rules, the testing conventions, and why
the schema is what it is.
Secrets
A secret never goes in heimdarr.toml: it would end up in a backup. Secrets
come from the environment, and fnox is what puts them there — fnox.toml
holds them encrypted to an age key, and fnox exec -- <command> decrypts them
into that one child process.
The committed fnox.toml is encrypted to a single age key that is not in the
repository, so a fresh clone can read nothing from it. Nothing else depends on
that: the OIDC secret and the metadata API keys are only needed against a real
IdP and real search. mise run dev and mise run smoke both work without them.
fnox doctor # is a key present, and does it decrypt?
mise run secrets:check # every configured secret resolves
mise run secrets:list # what is configured, and from where
mise run secrets:set OIDC_CLIENT_SECRET # add one; prompts, hidden input
fnox takes the age identity from ~/.config/fnox/age.txt, or from
FNOX_AGE_KEY_FILE if that points elsewhere. To use your own secrets, either add
your public key to recipients in fnox.toml and run fnox reencrypt -p age
(the existing recipient stays), or keep overrides in a fnox.local.toml, which
is gitignored and wins:
# fnox.local.toml
[secrets]
# An override need not be encrypted and need not come from a provider.
TMDB_API_KEY = { default = "dev-placeholder" }
The tasks that want a secrets-bearing environment ask fnox for one:
mise run serve # build, then the server with the secrets in the environment
mise run import # the import CLI, with HEIMDARR_URL/HEIMDARR_TOKEN from fnox
mise run import passes its arguments through, so mise run import -- watcharr --file export.json is the same command as heimdarr import watcharr --file export.json. Add HEIMDARR_URL and HEIMDARR_TOKEN with fnox set and the
import needs no flags at all.