fix(jellyfin): refuse a library that indexes files but fetches nothing

The Movies library had indexed 2,571 films and had never asked a provider
for a single one: no TMDb id, no plot, no rating, no poster. Nothing was
broken in the provider chain — the plugin is core in 12.4 and active, its
key is set, image.tmdb.org answers the container — and nothing was broken
in the write path: the overlay view has been read-write since the 2026-10-31
cutover, and movie.nfo lands on the NVMe upper layer today.

The state was two settings in the per-library option file,
config/root/default/<Library>/options.xml (Jellyfin 12 renamed it from
library.xml; the database stores none of these options, so a census that
greps for the old name and then queries the DB concludes nothing is
configured and goes hunting elsewhere):

  <EnableInternetProviders>false</EnableInternetProviders>
  <TypeOptions><ImageFetchers />   (empty, beside a populated
                                    <MetadataFetchers> and a fully
                                    populated <ImageFetcherOrder>)

With the internet switch off every refresh answers 204, stores nothing and
logs nothing, so the item count reads like a healthy scan — this passed the
deploy health gate, the Playwright suite and three agent audits. Order is
not enablement: the fetcher ORDER stays populated while the ENABLED set sits
empty, and the populated metadata list beside it makes the library look well.

verify now audits every library's options.xml and fails with `SILENT INDEX
<Library>: <what>` on providers-off or on a type whose image fetchers are
empty while its metadata fetchers are named; an options.xml that never
carried the switch (BoxSets, written before the property existed) is the
vendor default and stays accepted.

Vault: the page claimed artwork is a sidecar like movie.nfo. It is not — no
image saver is bound for any library, so art goes to the internal store and
Plex never reads it. The claim is corrected, and the RemoteImages diagnostic
that separates a provider fault from a library fault is written down with
the 12.x API shape traps (Items needs UserId; MetadataRefreshMode has no
Full/ValidationFailure, ReplaceAllMetadata=true is what re-runs providers).
This commit is contained in:
Frank committed 2026-11-01 10:31:42 -08:00
1 parent 7cf388367e
commit 52600b28ab
3 files changed
+187 -5

No files matched your search

+61 -1
View File
@@ -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/<Library>/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'
}