Plex.md and Jellyfin.md keep every law and point at the driver that now carries them, so the procedure has one owner and the pages keep the failure signatures. Jellyfin.md also records where `jellyfin.db` actually lives on this host (the quadlet's config bind, not the stock path) and demotes `immutable=1` on the `ApiKeys` read to the `-shm` fallback, because the database is WAL and `immutable` skips the log. Plex.md gains the live section keys and the `scanned_at == scannedAt` fact. Media Relayout.md's hand-off after an apply names both drivers and states the ordering law: Jellyfin first and alone, since its one door walks every library while Plex forks a scanner per section, and the array is what both share. Media Duplicate Prune.md gains the guard the 2026-11-04 hold taught: 32 files carrying real film titles were 8-20-second h264 clips (49 MiB, 25 of them in a `_release-group watermarks/` folder), invisible to every duplicate screen because nothing shares their size and the feature-length guard hides them. The census that finds the next batch is a duration floor, not a hash.
456 lines
28 KiB
Markdown
456 lines
28 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
|
||
|
||
Run the driver from the deploy checkout: `scripts/media-refresh-jellyfin.py
|
||
trigger`, then `… wait`. It calls the library settled only on the rule below — the
|
||
task seen `Running` (or a run that finished while it watched) first, then
|
||
`--require-quiet` reads with no scan and no writes — and refuses by name when the
|
||
scan never started, because a watcher that saw no `Running` would otherwise report
|
||
the pre-scan library as the fresh one. `… status --after $(stat -c %Y <journal>)`
|
||
is the "has the scan reached my moves" answer, read from the task's `EndTimeUtc`.
|
||
|
||
It carries every law below. Hand-written, the door is a `curl -K` on a mode-600
|
||
config: the scan key lives in `~/.config/secrets/jellyfin` (mode 600; the same
|
||
value is `JELLYFIN_API_KEY` in the ignored `dankserver/.env`) and rides as
|
||
`Authorization: MediaBrowser Token="…"` — 12.4 refuses a bare `X-Emby-Token` for
|
||
every key, valid or not — never as a header argument, because
|
||
`/proc/<pid>/cmdline` is world-readable. `recentMedia` counts
|
||
`BaseItems.DateModified` rows in `jellyfin.db`, which on this host sits in the
|
||
quadlet's config bind at `/home/dank/Containers/jellyfin/config/data/`, not the
|
||
stock `~/.local/share/jellyfin`.
|
||
|
||
`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). Before believing a 401, prove the
|
||
key against the server's own table — `select count(*) from ApiKeys where
|
||
AccessToken = ?` on `jellyfin.db` opened plain `mode=ro` (`immutable=1` only as
|
||
the fallback for a database whose `-shm` cannot be read; it skips the write-ahead
|
||
log, see [[Media Relayout]]). Reading progress with the same config trick takes
|
||
**one** URL: a config `url` line *plus* a command-line URL makes curl perform both
|
||
transfers, and the second dumps the whole `ScheduledTasks` body onto your stdout.
|
||
|
||
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`
|