Media Relayout.md hit the vault's 500-line gate the moment the tvdb contract landed, and the fat in it was not the new text: the Plex and Jellyfin scan blocks (35 lines) sat inside the relayout procedure even though they are the way you talk to those servers after ANY change, and two Laws bullets repeated the same facts a third time. Both blocks now live on [[Plex]] and [[Jellyfin]]; the relayout page keeps the two laws an operator needs at that moment — 204 is acceptance and anything else queued nothing, and one accepted refresh is the whole procedure, because a second moves `Scan Media Library` to `Cancelling` and every later 204 is discarded. Same pass, the dead numbers the vault's own law says to destroy: an applied music pass's date, an applied audiobook pass's move/park counts, and the 146 sweep stragglers of one specific first sweep. The laws they taught stay. 490 lines, `bats tests/docs_completeness.bats` 6/6 (length violation gone, link integrity clean).
232 lines
13 KiB
Markdown
232 lines
13 KiB
Markdown
---
|
|
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/<film>/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` → `<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:
|
|
|
|
```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`
|