--- type: reference title: Jellyfin description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status." tags: [jellyfin, dankserver, media-server] timestamp: 2026-10-30T00:00:00Z --- # Jellyfin Jellyfin is the free media server on Dankserver — a podman quadlet service (`jellyfin.service`, generated from `dankserver/jellyfin/quadlet/jellyfin.container`) serving music, movies, and shows over LAN. It replaced the earlier research brief; it runs alongside [[Plex]], which remains the primary server for some clients. **Host:** Dankserver (192.168.0.235) — **Access:** `http://192.168.0.235:8096` public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → [[Authentik]] outpost (post-auth route proxies `localhost:8096`). ## Facts (the repo quadlet; the live unit is still the pre-view generation — see Data layout) | Item | Value | |------|-------| | Unit | `~/.config/containers/systemd/jellyfin.container` → `jellyfin.service` (user manager), `Restart=always`, `RestartSec=5` | | Image | `ghcr.io/jellyfin/jellyfin:latest` | | Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp | | Data roots | `/home/dank/Containers/jellyfin/config:/config`, `/home/dank/Containers/jellyfin/cache:/cache` | | Media | `Volume=/var/lib/media-meta/view:/data` in git — the [[Media Metadata View]] overlay, **writable**, sidecars land on the NVMe and the [[Entropy Volume]] array underneath stays untouched. **The live unit is still pre-view: `/mnt/entropy:/data:ro`** — nothing is written into the library until the view is deployed | | GPU | none — no `AddDevice`; CPU decode only (jellyfin-ffmpeg path) | | Env quirks | `DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true`, `LD_PRELOAD=libjemalloc.so.2`, `MALLOC_TRIM_THRESHOLD_=131072` kept verbatim from the compose-era container (zero-drift cutover) | | Health | `curl -f http://localhost:8096/health` every 30 s | ## Data layout ``` /home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR) /home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME) /var/lib/media-meta/view -> /data (git: the overlay view) /mnt/entropy -> /data (live today: the array, read-only) ``` Library folders point at `/data` paths: stable across container recreation and identical to the raw array layout below. Which side of that path the container is on is the open question on this host — the installed unit binds the read-only array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31). `Local metadata` — save metadata to media folders — is enabled, and it is the reason the view exists: `movie.nfo`, `tvshow.nfo`, `episode.nfo` and artwork written once on the NVMe upper layer are files [[Plex]] can read too. While `/data` is bound read-only every item update logs the error below and no sidecar is written; Jellyfin becomes the fleet's only metadata writer with the deploy that raises the view. **Failure signature:** `System.IO.IOException: Read-only file system : '/data/Movies//movie.nfo'` means the container is binding the raw array instead of the view. On Dankserver that is today's expected state — the view has never been installed (`scripts/media-view.sh status`). Once it is deployed, the same line is a fault: the quadlet drifted or the mount died. ## Ports & access | Port | Proto | Purpose | |------|-------|---------| | 8096 | TCP | Web UI + API (`JELLYFIN_PublishedServerUrl=http://192.168.0.235:8096`) | | 8920 | TCP | DLNA/UPnP root device control | | 7359 | UDP | service discovery (client auto-discovery) | | 1900 | UDP | SSDP discovery | Client access paths and the public-exposure policy (auth wall required before any public vhost; Plex-style claim tokens do not exist here — Jellyfin uses its own users/API keys) follow the fleet [[Authentik]]/[[Anubis]] chain pattern. ## Authentik LDAP authentication (2026-09-25) Jellyfin has no native external-auth, so it authenticates users through the **LDAP-Auth plugin** (`jellyfin-plugin-ldapauth`) against an [[Authentik]] LDAP provider. A user logs in with their **authentik username + password**; the plugin binds as a service account, searches the directory, and rebinds as the user to verify the password. **authentik side** (on Dankclaw): - LDAP provider `Jellyfin LDAP` (Base DN `dc=franksdank,dc=cloud`, bind/search mode `direct`) + application `Jellyfin`. - LDAP outpost `Jellyfin LDAP Outpost` → container `authentik-outpost-ldap` (quadlet `dankclaw/authentik/quadlet/authentik-outpost-ldap.container`) publishing `389`/`636` on the Dankclaw host for cross-host binds. - Service account `svc-jellyfin-ldap` (DN `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud`). - Access groups `jellyfin_users` (search filter `(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`) and `jellyfin_admins` (admin filter `(cn=jellyfin_admins)`). Add a user to `jellyfin_users` to grant them Jellyfin access; `jellyfin_admins` grants the Jellyfin admin role. **Jellyfin side** (plugin config, set via admin → Plugins → LDAP-Auth): - Server `192.168.0.143:389` (plain LDAP over the trusted LAN), Base DN `dc=franksdank,dc=cloud`, bind user = the service-account DN, search attributes `uid,cn,mail,displayName`, uid attr `uid`, username attr `cn` `CreateUsersFromLdap=true`, all folders enabled. **Gotcha — the bind credential is a PASSWORD, not a token.** The LDAP bind is verified by authentik's default authentication flow (standard-password backend). The service account must have a password set (admin → user → Recovery → Set password); an API/app token is *not* accepted as the bind password and the bind fails with `flow error password: Invalid`. The service-account password is stored only in the Jellyfin plugin config (the `LdapBindPassword` field) — it is never committed to this repo. **Verification (2026-09-25):** `ldapsearch` bind + the `jellyfin_users` search both succeed from Dankclaw; a real user (`alex`, in `jellyfin_users`) logged into the web UI with their authentik password and the plugin created the matching Jellyfin user. **Gotcha — the login endpoint changed in Jellyfin 12.1.** The legacy `POST /Users/Auth/Password` route (and its `LegacyEmbyRouteRewriteMiddleware`) was removed; probing it now returns a silent 401 with no plugin log, which reads like an auth failure but is just a dead route. The web UI and modern clients use `POST /Users/AuthenticateByName` (with the `Authorization: MediaBrowser Client=…, DeviceId=…, Device=…, Version=…` header), which is where the LDAP plugin runs. Verified 2026-09-25: `alex` authenticates over `AuthenticateByName` (HTTP 200; full plugin flow logged — service bind, search, `ldapUid`, user bind, token). ## Metadata providers and their keys (2026-10-30) TMDb, OMDb, StudioImages and MusicBrainz are **core plugins** in Jellyfin 12 — no DLL, just a configuration file the running container owns under the data root. TVDB is not core (its metadata provider was removed in 10.9), so it is an official-catalog plugin. | Provider | Shape on disk | Key lives in | |---|---|---| | TMDb | `config/plugins/configurations/Jellyfin.Plugin.Tmdb.xml` → `` | `/home/dank/Containers/jellyfin/provider-keys` → `TMDB_API_KEY=` (mode 600) | | TVDB | `config/plugins/thetvdb_24.0.0.0/` + `…Jellyfin.Plugin.Tvdb.xml` → `` | the same file → `TVDB_API_KEY=` | | OMDb | `…Jellyfin.Plugin.Omdb.xml` | **not configured** — its key is issued by email, 1 000 req/day, and its ToS forbids storing the returned data; ratings stay Jellyfin's own lookup | **None of it is tracked in git.** Those XML files belong to the container and hold credentials, and `tests/jellyfin_providers.bats` fails the suite if one is ever committed. The keys themselves sit in the per-service host env file that is the fleet's convention for container credentials (grafana, caddy, pihole, copyparty, romm, seerr): `provider-keys`, mode 600, beside the data root. `scripts/jellyfin-providers.sh` is the only thing that writes the XML: ``` scripts/jellyfin-providers.sh plan # what would change; prints no key scripts/jellyfin-providers.sh install # plugin + keys, previous copy to trash scripts/jellyfin-providers.sh verify # plugin + key LENGTHS, never key bytes ``` - **A key never enters git, argv, or the tool's output.** The installer reads that 600-perm file itself (a value in argv is world-readable through `/proc//cmdline`) and refuses it when it is group- or world-readable — an exposed key is an error, not a warning. A key that shows up in a transcript or `ps` output has leaked: rotate it with the provider, rewrite `provider-keys`, re-run `install`. - **The TVDB artifact is pinned three ways**: sha256 `6302fba3857bdde8d186dd099fac09ca43247041f531a528a2c788add70a5319`, the official plugin guid `a677c0da-fac5-4cde-941a-7134223f14c8`, and version `24.0.0.0` (`targetAbi 12.0.0.0`, which fits the deployed 12.4.0). A sha match alone only proves the bytes are the bytes fetched — the guid check is what says it is the official plugin. Rollback: move `thetvdb_24.0.0.0/` into the trash and revert the pin; a second `thetvdb_*` directory is a refusal, never a quiet second copy. - **The homelab TVDB key is a licensed key** (its JWT claims say `community_supported: false`), so it needs **no PIN** — the optional `TVDB_PIN=` line exists only for a subscriber-shaped key. - **TVDB is installed and keyed but is not a metadata agent**: TMDb still identifies everything. Enabling TVDB as a provider would re-fetch show metadata across the library, so it is a deliberate, separate change. - **The deployer places the keys before it restarts jellyfin** (`ensure_provider_keys` in `scripts/deploy-remote.sh`) and warns instead of failing when `provider-keys` is absent, so a host without it still deploys. - **Keys and plugins are read at start-up**: after a manual `install` the change is inert until `./scripts/deploy.sh dankserver --service jellyfin`. - TMDb's terms are the reason this stays narrow: attribution required, no caching beyond 6 months, no derivative works. Fine for a personal homelab, and the reason no bulk enrichment pass runs against it. ## Configuration workflow 1. Create libraries pointing at `/data` folders (movies, shows, audio). 2. Set metadata/language preferences (`LANG=en_US.UTF-8` pinned in the unit). 3. Configure clients to use `http://192.168.0.235:8096` or the guarded vhost. ## Triggering a scan The scan key lives in `~/.config/secrets/jellyfin` (mode 600; the same value is `JELLYFIN_API_KEY` in the ignored `dankserver/.env`). It rides a mode-600 curl config, never a header argument, in the `MediaBrowser` form: 12.4 authenticates only `Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token` for every key, valid or not. Before believing a 401, prove the key against the server's own table — `select count(*) from ApiKeys where AccessToken = ?` on `jellyfin.db` opened `mode=ro&immutable=1`. The backslashes below matter: ```bash umask 077 { printf 'header = "Authorization: MediaBrowser Token=\\"%s\\""\n' \ "$(cat ~/.config/secrets/jellyfin)" echo 'url = "http://127.0.0.1:8096/Library/Refresh"' echo 'request = "POST"'; } >/tmp/jf.hdr curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" shred -u /tmp/jf.hdr ``` `204` is acceptance; anything else queued no scan, so re-issue the identical request instead of diagnosing. A `401` from a key that answers `200` on a hand-typed request is this block's quoting, not the server: bash `printf` collapses `\"` to `"`, curl ends the quoted value at that quote, and Jellyfin receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answers 204, `\"` answers 401 — both run on this host). One accepted refresh is the whole procedure. A second one while a scan runs moves `Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every later `204` is accepted and then discarded — the status code stops meaning anything. The fix is `systemctl --user restart jellyfin.service` (never `deploy.sh`: that runs the never-tested metadata-view cutover), one POST after health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the response code. **A scan that must see a new folder has to be recursive.** `POST /Library/Refresh` is the incremental pass, and inotify does not cross a rootless podman bind, so folders created on the host stay invisible to it. The move is `POST /Items/{id}/Refresh?Recursive=true` (204) per library, the id from `GET /Library/VirtualFolders` — its field is `ItemId`, not `Id` — and that same record's `RefreshStatus` is the progress read. ## See Also - [[Plex]] — co-resident media server on the same volume - [[Entropy Volume]] — the 91 T array mounted read-only at `/data` - [[Caddy]] · [[Anubis]] · [[Authentik]] — edge chain for `jelly.franksdank.cloud`