Files
Frank 2e69215920 docs(jellyfin): name the library doors, the watcher, and the phantom records
Measured 2026-11-02 against the live server. The per-library posture goes on the
page because two libraries are silent file indexes right now: Shows and Music
carry EnableInternetProviders=false, so a refresh on either answers 204 and
stores nothing — any metadata or artwork work there starts by flipping the
switch, not by scanning. Movies additionally has EnableRealtimeMonitor=false, so
a folder moved into /data/Movies stays invisible until a recursive refresh names
it; "Jellyfin does not see the file" is the designed answer, not a fault.

Recorded with it: the option door on 12.x is POST
/Library/VirtualFolders/LibraryOptions (GET answers 405 — that is how the route
is found) while the 10.x /Update and /Refresh routes answer 404 outright, and
the VirtualFolders DTO is PascalCase, so a census written against the old
camelCase keys reads None and misreports a healthy library as unconfigured.
A library record can also be a phantom: a 2026-11-01 capture carried Shows2,
Shows3 and Music2 with empty Locations while only four libraries existed, so the
live set is the entries with non-empty Locations and never the record count.
2026-11-02 02:12:19 -08:00

305 lines
17 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 — 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` and `episode.nfo` 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.
**Artwork is not one of those sidecars.** No image saver is bound for any
library, so posters and fanart go to the internal store
(`config/metadata/library/<xx>/<item-id>/poster.jpg`), which [[Plex]] never
reads — movie art has never existed beside the media on either layer.
**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.
**`EnableInternetProviders` false turns a library into a silent file index.**
Every refresh answers `204`, stores nothing and logs nothing — no provider ids,
no plot, no rating, no art — while the item count reads like a healthy scan. The
switch lives in `config/root/default/<Library>/options.xml`, Jellyfin 12's name
for the per-library option file: grepping for the old `library.xml` concludes "no
per-library options exist", and the database holds none, so the trail dies there.
Two more traps in the same file: `TypeOptions/ImageFetchers` is the *enabled* set,
so an empty `<ImageFetchers />` switches art off while `ImageFetcherOrder` stays
fully populated — order is not enablement — and the populated `<MetadataFetchers>`
built next to it makes the library look healthy.
**Live posture, measured 2026-11-02** from `GET /Library/VirtualFolders`. The DTO is
PascalCase — `Name`, `CollectionType`, `Locations`, `ItemId`, `RefreshStatus` — and a
census written against the 10.x camelCase keys reads `None` for everything, which
looks exactly like an unconfigured library.
| Library | `CollectionType` | `EnableInternetProviders` | `EnableRealtimeMonitor` |
|---|---|---|---|
| `Movies` | movies | true (fixed 2026-11-02) | **false** |
| `Shows` | tvshows | **false** | true |
| `Music` | music | **false** | true |
| `Collections` | boxsets | **false** | false |
`Shows` and `Music` are silent file indexes in the sense above: a refresh answers
`204`, stores nothing, logs nothing. Any metadata or artwork work on either one
starts by flipping the switch, never by firing a scan.
**The per-library option door on 12.x is `POST /Library/VirtualFolders/LibraryOptions`**
— `GET` there answers `405`, which is how the route announces itself. The 10.x doors
are gone: `/Library/VirtualFolders/Update` and `/Library/VirtualFolders/Refresh`
answer `404` on this build, so every recipe naming them fails outright.
**A library record can be a phantom.** A per-library option capture taken 2026-11-01
carried seven entries — `Shows2`, `Shows3`, `Music2` among them, each with
`Locations: []` and a live-looking `ItemId` — while `GET /Library/VirtualFolders` the
same day listed four. A numeric suffix on a library name is what a deleted and
recreated library leaves behind. The live set is the entries whose `Locations` are
non-empty; count those and never the records.
**`RemoteImages` is the call that separates a provider fault from a library
fault.** `GET /Items/{id}/RemoteImages?Type=Primary` returns the provider's real
poster list even with every automatic pass switched off — plugin, key and CDN all
proven by that one response — so a refresh that stores nothing beside it is a
library-switch problem, never a provider problem. It is also the delivery door:
`POST /Items/{id}/RemoteImages/Download` saves the chosen image to the internal
store on a build where the automatic image pass will not run at all — it is how
the Movies library got its art: 2,515 of 2,564 films carry a primary now, and the
rest have none in TMDb (shorts like `Astartes`, files whose names match nothing).
**API shape on 12.x:** `/Items` and `/Items/{id}` need `UserId` when
authenticated by API key — without it the answer is `400 Error processing
request`, which reads exactly like an authentication failure. `MetadataRefreshMode`
accepts `Default` (and `Realtime`) only; `Full` and `ValidationFailure` are
rejected outright, and what forces the providers to re-run is
`ReplaceAllMetadata=true`.
## 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`, 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.
**`Movies` has no watcher at all.** `EnableRealtimeMonitor` is per library (table in
*Data layout*) and it is false for `Movies` and `Collections`. Nothing notices a
folder created under `/data/Movies`: it stays invisible until a recursive refresh
names it. So every pass that writes into a library ends by firing one, and "Jellyfin
does not see the file I just moved in" is the designed answer, not a fault to chase.
## 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`