A per-episode RemoteImages query is never cached, so the same campaign that dressed 620 series in an afternoon costs ~11 s per episode for one hit in five. Across 42k episodes that is days of provider traffic to dress a fifth of them, and clients show series and season art regardless. Recorded so nobody runs the pass on a hunch; the door and the resumable log are the same if Frank orders it.
19 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. |
|
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).
The Download door's exact wire shape: the list response carries the address in
Url — there is no ImageUrl field — and the POST takes camelCase query params,
?type=Primary&imageUrl=<Url>, answering 204; the stored tag then appears in
ImageTags. PascalCase params, or an imageUrl left empty, answer 500, which
reads as a server fault and is a field-name fault.
RemoteImages answers empty for an item with no ProviderIds — no provider id,
nothing to look up. Art coverage is capped by identification, not by the image door:
the items a campaign returns nothing for are the unidentified ones, and no image
retry will change that.
Series and season art is fetched; episode stills are not, deliberately. Every episode query is a fresh provider round-trip — nothing caches per episode — measured at ~11 s per item beside ~2 s per series on the same host, with one hit in five in a sample. Across 42k episodes that is days of provider traffic to dress a fifth of them, and clients display the series and season art anyway. If episode stills are ever ordered, the loop is the same door and its log makes the pass resumable.
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 DNdc=franksdank,dc=cloud, bind/search modedirect) + applicationJellyfin. - LDAP outpost
Jellyfin LDAP Outpost→ containerauthentik-outpost-ldap(quadletdankclaw/authentik/quadlet/authentik-outpost-ldap.container) publishing389/636on the Dankclaw host for cross-host binds. - Service account
svc-jellyfin-ldap(DNcn=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)) andjellyfin_admins(admin filter(cn=jellyfin_admins)). Add a user tojellyfin_usersto grant them Jellyfin access;jellyfin_adminsgrants 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 DNdc=franksdank,dc=cloud, bind user = the service-account DN, search attributesuid,cn,mail,displayName, uid attruid, username attrcnCreateUsersFromLdap=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 orpsoutput has leaked: rotate it with the provider, rewriteprovider-keys, re-runinstall. - The TVDB artifact is pinned three ways: sha256
6302fba3857bdde8d186dd099fac09ca43247041f531a528a2c788add70a5319, the official plugin guida677c0da-fac5-4cde-941a-7134223f14c8, and version24.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: movethetvdb_24.0.0.0/into the trash and revert the pin; a secondthetvdb_*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 optionalTVDB_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_keysinscripts/deploy-remote.sh) and warns instead of failing whenprovider-keysis absent, so a host without it still deploys. - Keys and plugins are read at start-up: after a manual
installthe 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
- Create libraries pointing at
/datafolders (movies, shows, audio). - Set metadata/language preferences (
LANG=en_US.UTF-8pinned in the unit). - Configure clients to use
http://192.168.0.235:8096or 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.
The door that scans is POST /Library/Refresh (204), and ScheduledTasks is the
progress read. Measured 2026-11-02 against a Shows library that had never been
identified: the per-library POST /Items/{id}/Refresh?Recursive=true answers 204
and queues nothing — item census identical afterwards, Scan Media Library
idle, journal silent — and it is still an empty 204 after a service restart, so
that status code carries no information at all. /Library/Refresh walks every
library; within seconds the journal names folders (…/TVShows/…,
EpisodeMetadataService) and GET /ScheduledTasks reports
Scan Media Library | Running | 75 % while the process sits near 200 % CPU.
RefreshStatus on the GET /Library/VirtualFolders record is not a progress
read. It stayed Idle through a scan that was verifiably running at 75 %. The
record's useful field is ItemId, and the live library set is still the entries
whose Locations are non-empty.
inotify does not cross a rootless podman bind, so folders created on the host
stay invisible until a pass like this runs; every pass that writes into a library
ends by firing one.
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