Files
serverAppConfigs/serverAppConfigs-docs/Dankserver/Jellyfin.md
T
Frank b7c19592e5 docs(jellyfin): record the live view bind and the library-coverage law
The page still described the pre-cutover generation ("the live unit is
still pre-view", "/mnt/entropy -> /data (live today, read-only)") after
the 2026-10-31 cutover deployed the overlay. Corrected, and added the law
the census tonight taught: Jellyfin's four libraries cover /data/Movies,
TVShows, Music and the collections dir, so anything under /data/media-park
is invisible by configuration — a recursive refresh there returns 204 and
indexes nothing. Check coverage before re-scanning.
2026-10-31 19:43:35 -07:00

13 KiB

type, title, description, tags, timestamp
type title description tags timestamp
reference Jellyfin Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status.
jellyfin
dankserver
media-server
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 — deployed as the live unit on 2026-10-31)

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 — the Media Metadata View overlay, writable, live since the 2026-10-31 cutover: sidecars land on the NVMe and the Entropy Volume array underneath stays untouched
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     (the overlay's lower layer; also the rollback bind)

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 sees is the Media Metadata View overlay, live since the 2026-10-31 cutover: /var/lib/media-meta/view:/data, read-write.

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 — and since the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only metadata writer, and every sidecar it saves is an SSD byte the array never sees.

Library coverage is the first thing to check when a title is missing. The four libraries are movies → /data/Movies, tvshows → /data/TVShows, music → /data/Music, boxsets → /config/data/collections (GET /Library/VirtualFolders). /data/media-park is in none of them, so everything filed under it — the _unsorted release bundles and the extras/ folders the relayout wrote there — is invisible to Jellyfin by configuration, not by fault: a recursive refresh answers 204, burns CPU for minutes and indexes nothing, because it never looks there. Check coverage before re-scanning.

Failure signature: System.IO.IOException: Read-only file system : '/data/Movies/<film>/movie.nfo' means the container is binding the raw array instead of the view — always a fault on this host since the 2026-10-31 cutover. scripts/media-view.sh status says which: the quadlet drifted back to the raw array, 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 → <TmdbApiKey> /home/dank/Containers/jellyfin/provider-keys → TMDB_API_KEY= (mode 600)
TVDB config/plugins/thetvdb_24.0.0.0/ + …Jellyfin.Plugin.Tvdb.xml → <ApiKey> 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/<pid>/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:

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, 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