diff --git a/scripts/jellyfin-providers.sh b/scripts/jellyfin-providers.sh index cc95ec2..c982b65 100755 --- a/scripts/jellyfin-providers.sh +++ b/scripts/jellyfin-providers.sh @@ -35,7 +35,8 @@ # Subcommands # plan print the resolved shape and every pending change, touch nothing # install fetch+verify the plugin, then write the keys (backing up first) -# verify prove the plugin and both keys are actually in place +# verify prove the plugin and both keys are in place, and that no library +# is a silent file index (providers off / image fetchers empty) # # Nothing here restarts Jellyfin: the deployer owns that # (`scripts/deploy.sh dankserver --service jellyfin`), and the plugin is read at @@ -340,6 +341,64 @@ cmd_install() { fi } + +# A library whose options.xml carries EnableInternetProviders=false, or an empty +# ImageFetchers list beside a populated MetadataFetchers list, is a SILENT FILE +# INDEX: it indexes every file, answers 204 to every refresh, stores no provider +# id, plot, rating or artwork, and logs nothing — so the item count reads like a +# healthy scan. Jellyfin 12 keeps these options in root/default//options.xml +# (it was library.xml) and stores NONE of them in the database, so this audit is +# the only place the state can be read without a browser session. +verify_library_options() { + local root="$JF_ROOT/root/default" f lib problems + if [ ! -d "$root" ]; then + printf '= no library options under %s (nothing audited)\n' "$root" + return 0 + fi + local rc=0 + for f in "$root"/*/options.xml; do + [ -f "$f" ] || continue + lib="$(basename "$(dirname "$f")")" + problems="$(python3 -s - "$f" <<'PY' +import sys, xml.dom.minidom as X +root = X.parse(sys.argv[1]).documentElement +def child(el, name): + for n in el.childNodes: + if n.nodeType == n.ELEMENT_NODE and n.tagName == name: + return n +def flag(el, name): + n = child(el, name) + return None if n is None else (n.firstChild.data.strip() if n.firstChild else "") +def names(el, name): + n = child(el, name) + return None if n is None else [s.firstChild.data.strip() for s in n.childNodes + if s.nodeType == s.ELEMENT_NODE and s.firstChild] +bad = [] +ip = flag(root, "EnableInternetProviders") +if ip is not None and ip.lower() != "true": + bad.append("EnableInternetProviders=%s" % (ip or "empty")) +types = child(root, "TypeOptions") +if types is not None: + for t in types.childNodes: + if t.nodeType != t.ELEMENT_NODE or t.tagName != "TypeOptions": + continue + meta = names(t, "MetadataFetchers") or [] + if meta and not (names(t, "ImageFetchers") or []): + bad.append("%s: ImageFetchers empty while MetadataFetchers names %d" + % (flag(t, "Type") or "?", len(meta))) +print("; ".join(bad)) +PY +)" + if [ -n "$problems" ]; then + printf 'SILENT INDEX %s: %s\n' "$lib" "$problems" >&2 + rc=1 + else + printf '= %s: library options fetch from providers\n' "$lib" + fi + done + return "$rc" +} + cmd_verify() { check_root local bad=0 @@ -378,6 +437,7 @@ cmd_verify() { printf '%s %s chars\n' "$elem" "${#n}" fi done + verify_library_options || bad=1 [ "$bad" -eq 0 ] || return 1 printf 'providers configured\n' } diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index 395545a..ccdc333 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -46,10 +46,14 @@ 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`, `episode.nfo` and artwork -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. +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///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`, @@ -66,6 +70,32 @@ 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//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 `` switches art off while `ImageFetcherOrder` stays +fully populated — order is not enablement — and the populated `` +built next to it makes the library look healthy. + +**`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. + +**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 | diff --git a/tests/jellyfin_providers.bats b/tests/jellyfin_providers.bats index 50aab63..c9e71d0 100755 --- a/tests/jellyfin_providers.bats +++ b/tests/jellyfin_providers.bats @@ -24,6 +24,9 @@ # 9. no plugin configuration XML is tracked in git, the deployer places the # keys BEFORE it restarts Jellyfin, and the planner routes a change to this # installer to jellyfin. +# 10. verify refuses a library that is a SILENT FILE INDEX — internet providers +# switched off, or a type whose ImageFetchers is empty while its +# MetadataFetchers list is populated (the 2026-11-01 missing-posters fault). load helpers @@ -59,6 +62,32 @@ setup() { TRASH="$BATS_TEST_TMPDIR/trash" SEC="$BATS_TEST_TMPDIR/secrets" mkdir -p "$JF/plugins/configurations" "$TRASH" "$SEC" + # The per-library option file Jellyfin 12 owns (it was library.xml). Conforming + # by default so the pre-existing verify tests keep testing what they tested. + mkdir -p "$JF/root/default/Movies" + cat >"$JF/root/default/Movies/options.xml" < + + true + + Nfo + + + + Movie + + TheMovieDb + + + TheMovieDb + + + TheMovieDb + + + + +XML cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml" < @@ -270,6 +299,69 @@ XML printf '%s\n' "${lines[@]}" | grep -qE 'TmdbApiKey .*[0-9]+ chars' } +@test "verify refuses a library whose internet providers are switched off" { + # The whole library becomes a file index: refreshes answer 204 and store + # nothing, and the item count still looks like a healthy scan. + prov install >/dev/null + sed -i 's#true#false#' \ + "$JF/root/default/Movies/options.xml" + run prov verify + [ "$status" -ne 0 ] + printf '%s\n' "${lines[@]}" | grep -qiF 'SILENT INDEX Movies' + printf '%s\n' "${lines[@]}" | grep -qiF 'EnableInternetProviders' +} + +@test "verify refuses a type with no image fetchers while metadata fetchers are named" { + prov install >/dev/null + cat >"$JF/root/default/Movies/options.xml" < + + true + + + Movie + + TheMovieDb + + + + TheMovieDb + + + + +XML + run prov verify + [ "$status" -ne 0 ] + printf '%s\n' "${lines[@]}" | grep -qiF 'ImageFetchers empty' +} + +@test "an older options.xml that never carried the switch is not refused" { + # BoxSets on this host were written before the property existed; absence is + # the vendor default, not a switched-off library, so it must not fail. + prov install >/dev/null + mkdir -p "$JF/root/default/Collections" + cat >"$JF/root/default/Collections/options.xml" < + + + + BoxSet + + TheMovieDb + + + TheMovieDb + + + + +XML + run prov verify + [ "$status" -eq 0 ] + printf '%s\n' "${lines[@]}" | grep -qiF '= Collections' +} + @test "no plugin configuration XML is tracked in this repo" { # These files hold provider keys and are owned by the running container; # tracking them would put credentials in git and guarantee drift.