450 lines
27 KiB
Markdown
450 lines
27 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-11-03T11:40: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`). The review pile — `/data/NeedsProcessing`, the
|
||
former `media-park`, drained into it 2026-11-02 — is in **none** of them, so
|
||
everything filed 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.
|
||
|
||
**Which read proves an item is indexed: the folder view, never the recursive type
|
||
list.** `GET /Items?IncludeItemTypes=Movie&Recursive=true` answered 2,101 items on
|
||
2026-11-02 while `GET /Items?ParentId=<Movies ItemId>` listed 2,548 children — every
|
||
folder on disk — and the eight folders the recursive read "lost" (three `Gundam 00
|
||
Special Edition`, five `Sailor Moon Memorial`) each answer as `Type: Movie` through
|
||
`SearchTerm` and sit in the folder listing. The two reads are not interchangeable, so
|
||
the recursive list is a wrong instrument for "did the refresh index what I moved": its
|
||
shortfall is grouping, not missing media, and acting on it starts a scan that fixes
|
||
nothing. Verify per item — `ParentId` listing or `SearchTerm` — and use the recursive
|
||
count only as a trend line.
|
||
|
||
**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`, cross-checked
|
||
against each library's `config/root/default/<Library>/options.xml`. The switches are NOT
|
||
top-level DTO keys: the folder carries `Name`, `CollectionType`, `Locations`, `ItemId`,
|
||
`PrimaryImageItemId`, `RefreshStatus` and `LibraryOptions`, and every per-library option
|
||
sits inside `LibraryOptions`. Reading `EnableInternetProviders` off the folder itself
|
||
answers `None` for all four — the camelCase trap one level deeper, and it looks exactly
|
||
like an unconfigured library.
|
||
|
||
| Library | `CollectionType` | `EnableInternetProviders` | `EnableRealtimeMonitor` |
|
||
|---|---|---|---|
|
||
| `Movies` | movies | true (fixed 2026-11-02) | **false** |
|
||
| `Shows` | tvshows | true (fixed 2026-11-02) | true |
|
||
| `Music` | music | **false** | true |
|
||
| `Collections` | boxsets | **false** | false |
|
||
|
||
`Music` and `Collections` 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. `Shows` sat in that set
|
||
until 2026-11-02; the door is `POST /Library/VirtualFolders/LibraryOptions` with
|
||
the library's own `ItemId` (below), and `scripts/jellyfin-providers.sh verify` is
|
||
the one command that says which libraries are still in it.
|
||
|
||
**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.
|
||
|
||
**A `Download` pass reports zero only when it is pinned to one provider.** A TMDb-pinned
|
||
campaign over `Shows` classified every item in 20 s, downloaded nothing, and concluded that
|
||
identification was the only lever left. The same door queried with no `Providers=` filter
|
||
answered for 16 seasons that same day — every offer from `TheTVDB`, which the pinned form
|
||
never asks — and all 16 are dressed now. A pinned answer proves one provider is empty; the
|
||
unpinned answer is the provider census, and a TV art loop that pins is simply the wrong tool.
|
||
Post-fill the artless remainder measures 86 items (80 seasons, 6 series): 61 carry no provider
|
||
id at all — the unnamed long tail (`3a BBW - Series (2010-11)`, `History Of Africa With Zeinab
|
||
Badawi`, `(Warhammer TV)`-shaped directories), where the lever is folder names or an identify
|
||
pass — and 25 hold an id and draw no offer from any provider. No image retry moves either
|
||
group, which is the half of the old conclusion that survives.
|
||
|
||
**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/{id}` needs `UserId` when authenticated by API key — without
|
||
it the answer is `400 Error processing request`, which reads exactly like an authentication
|
||
failure, and it bites hardest as a *verification* read directly after a write that succeeded.
|
||
The `/Items` list route, `?Ids=<id>` included, needs no `UserId`, so verify writes there.
|
||
`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 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.
|
||
- **A plugin owns its own config file, under its own root, and omits null
|
||
settings** — so the element an installer wants to fill may simply not exist,
|
||
and its root is `<PluginConfiguration>`, not `<Configuration>`. TVDB's file had
|
||
no `<ApiKey>` element at all, which is why `install` refused with
|
||
`no place to write <ApiKey>` from October until the anchor fix: the secret sat
|
||
valid in `provider-keys` the whole time, and `verify`'s `MISSING key` was this
|
||
fault, not a missing key. The writer now anchors on the file's own root and
|
||
still refuses by name when a file has none.
|
||
- **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.
|
||
|
||
**The door that scans is `POST /Library/Refresh` (204), and `ScheduledTasks` is
|
||
the progress read.** A task that has never completed answers
|
||
`LastTaskResult=None, EndTimeUtc=None`, and that pair is what separates "never
|
||
ran" from "finished a moment ago" — the difference between a door that is dead
|
||
and one that merely answered `204`.
|
||
|
||
**The settle read is `GET /ScheduledTasks`: the row whose `Name` is exactly
|
||
`Scan Media Library`, and its `State` (`Idle`|`Running`).** `/Library/ScanStatus`
|
||
is a 404 on 12.4, so a watcher built on the old endpoint reports nothing and a
|
||
scanning server looks an idle one. Call it settled only after a `Running`
|
||
sighting followed by two consecutive `Idle`s: sub-tasks leave gaps that a single
|
||
poll reads as finished, and a census taken in one reports the pre-scan library.
|
||
|
||
**The per-library `POST /Items/{id}/Refresh` queues nothing, and the provider
|
||
switch is not the reason.** Measured twice on 2026-11-02 against `Shows`: before
|
||
the flip and again after it, the call answered `204`, the journal wrote zero lines
|
||
over the following ten minutes, and `Scan Media Library` kept its never-run
|
||
signature; a service restart changes nothing. `POST /Library/Refresh` does run —
|
||
`204`, then `Running` inside 15 s at 33 % CPU — but it walks every library, so on
|
||
this build a library-scoped metadata pass has no working door and the global one
|
||
is the only lever.
|
||
|
||
**That endpoint takes its parameters from one source.** The documented query form
|
||
(empty body) and the JSON body form (no query) each parse; supplying both at once
|
||
answers `400` with an empty body whatever the values, and `ValidationFailure` is
|
||
rejected in the body while the query form accepts it.
|
||
|
||
**`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.
|
||
|
||
## API-key ledger and rotation
|
||
|
||
`/Auth/Keys` is the ledger. `GET` lists every key **with its `AccessToken` in
|
||
cleartext**, so an admin token in hand prints the whole fleet's keys;
|
||
`POST /Auth/Keys?app=<name>` mints one and answers `204` **with no body** — the
|
||
new token is read back from the `GET`, never from the create response.
|
||
`DELETE /Auth/Keys/{token}` revokes **by the token string**, and a parameter that
|
||
matches nothing answers `204` too, so `204` there is not proof anything was
|
||
removed. Discover a route's verb from the `Allow` header of an `OPTIONS` (or
|
||
`GET`) response — never by firing `DELETE` at a guess.
|
||
|
||
- `IsActive` reads `False` on rows that authenticate `200`. It is not a usability
|
||
signal; only an authenticated request is.
|
||
- `DateLastActivity` never moves off `0001-01-01` on 12.4, so "is this key in
|
||
use?" cannot be answered from the ledger. The census is repo + host credential
|
||
stores + the `Devices` table (whose `DateLastActivity` *is* real).
|
||
- Rotation order: mint → rewrite **both** stores (`~/.config/secrets/jellyfin`
|
||
and `JELLYFIN_API_KEY` in the ignored `dankserver/.env`) → prove the new key on
|
||
`/System/Info` → `DELETE` the old token → prove `401`. Revoking while a store
|
||
still holds that value breaks the scan door silently.
|
||
- Blast radius as measured 2026-11-03: no repo script reads `JELLYFIN_API_KEY`;
|
||
Seerr keeps a key of its own in its own DB (which of its two rows it uses was
|
||
never confirmed), so revoke by token and never by app name.
|
||
|
||
## Big-screen theme (the TV)
|
||
|
||
`dankserver/jellyfin/theme/wmc/theme.css` is repo-owned and bind-mounted over the
|
||
image's **Media Center** theme slot (the `Volume=` line in
|
||
`dankserver/jellyfin/quadlet/jellyfin.container`). It exists for
|
||
`danklivingroom`, which renders this server-served web app at 3840×2160 with no
|
||
client-side scaling, so 14 px text arrives on a panel read from three metres.
|
||
|
||
The file is four rules, and its first line is the design:
|
||
`@import url("../dark/theme.css")` pulls in the **live upstream** dark palette, so
|
||
the repo never vendors a CSS snapshot and a Jellyfin image bump cannot strand one.
|
||
That works because jellyfin-web applies a theme as
|
||
`<link rel="stylesheet" href="themes/<id>/theme.css">` — a relative URL resolved
|
||
against the CSS file, not the document. The size lives in one number:
|
||
`--dank-big-screen-zoom` (1.75 ≈ three metres; 1.5 mild, 2.0 projector). `zoom` on
|
||
the root element is the right lever: the card-grid code measures layout pixels, so
|
||
fewer, larger columns are computed rather than overflow produced.
|
||
|
||
- **Adoption is per device, and it stays that way.** The theme a client renders
|
||
comes from its own settings (localStorage first), so there is no server-side
|
||
"everyone gets the big UI" knob. On the screen that wants it:
|
||
**Display Settings → Theme → Media Center.** That is also the honest label —
|
||
the dropdown names are compiled into the bundle, so a "Big Screen" entry will
|
||
never appear; do not go looking for one.
|
||
- The mount is `:ro` and the app only fetches the file, so the failure mode of a
|
||
stale `@import` target is slightly wrong colors, never a dead web app.
|
||
- **Editing the CSS needs no restart.** The bind hands out the host file's live
|
||
bytes, so a changed zoom number is on screen at the next page load. The planner
|
||
agrees by construction: only `dankserver/<svc>/quadlet/**` maps to a service, so
|
||
a `theme/**`-only commit plans nothing — that is correct, not a gap to "fix",
|
||
because restarting a media server for a font size is the wrong trade. Only the
|
||
first deploy of the `Volume=` line restarts anything.
|
||
- **Rollback is one line:** drop the `Volume=` and redeploy; nothing in the
|
||
database records the theme, so no cleanup follows.
|
||
|
||
## 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`
|