Merge pull request 'docs(jellyfin): the door that scans is /Library/Refresh, and the art door names its field' (#120) from docs/jellyfin-scan-door into main

This commit was merged in pull request #120.
This commit is contained in:
dank committed 2026-11-02 07:16:52 -08:00
commit 21c98bd485
2 files changed
+78 -13

No files matched your search

+6 -1
View File
@@ -735,7 +735,12 @@ linked runbooks.
**Ops**
- Never `pkill -f` a pattern that appears in your own command line — it kills your ssh session.
Use `pkill -x <exact-name>`.
Use `pkill -x <exact-name>`, or the bracket trick — `pkill -f "serve[-]script.sh"` —
when the pattern has to be named: the bracket makes the pattern stop matching the
command line that carries it. Quote it: a zsh remote glob-expands an unquoted `[s]`
and the kill silently never runs. Re-hit 2026-11-02 —
`ssh host 'pkill -f jfshowswatch.py; …'` killed the remote shell mid-command, and
ssh exits **255 with zero output**, which reads exactly like a network fault.
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in committed scripts
(no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts without it.
- **ComfyUI on Dankclaw runs with GPU access** (`/dev/kfd` + `/dev/dri` via `AddDevice`,
+72 -12
View File
@@ -89,13 +89,16 @@ looks exactly like an unconfigured library.
| Library | `CollectionType` | `EnableInternetProviders` | `EnableRealtimeMonitor` |
|---|---|---|---|
| `Movies` | movies | true (fixed 2026-11-02) | **false** |
| `Shows` | tvshows | **false** | true |
| `Shows` | tvshows | true (fixed 2026-11-02) | true |
| `Music` | music | **false** | true |
| `Collections` | boxsets | **false** | false |
`Shows` and `Music` are silent file indexes in the sense above: a refresh answers
`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.
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
@@ -119,6 +122,33 @@ store on a build where the automatic image pass will not run at all — it is ho
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` campaign over a library that is already dressed does nothing in
seconds, and that is the measurement proving identification is the only lever
left.** Run over all 753 series and 2,805 seasons of `Shows` on 2026-11-02 it
classified 3,558 items in 20 s: 2,908 already carried a primary, 624 have no
provider id at all, 26 have an id and no poster in TMDb — zero downloads. The
artless remainder is not an image-door problem: it is the unnamed long tail (clip
collections, `(Warhammer TV)`-shaped directories), and the lever is folder names
or an identify pass, never another retry loop.
**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`
@@ -232,9 +262,17 @@ scripts/jellyfin-providers.sh verify # plugin + key LENGTHS, never key bytes
- **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.
- **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.
@@ -284,12 +322,34 @@ 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.
**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 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