feat(media): per-episode TV duplicate census, and the Jellyfin library that indexed but fetched nothing #116

Merged
dank merged 4 commits from feat/jellyfin-provider-keys into main 2026-11-02 02:03:03 -08:00
3 changed files with 187 additions and 5 deletions
Showing only changes of commit 52600b28ab - Show all commits

No files matched your search

+61 -1
View File
@@ -35,7 +35,8 @@
# Subcommands # Subcommands
# plan print the resolved shape and every pending change, touch nothing # plan print the resolved shape and every pending change, touch nothing
# install fetch+verify the plugin, then write the keys (backing up first) # 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 # Nothing here restarts Jellyfin: the deployer owns that
# (`scripts/deploy.sh dankserver --service jellyfin`), and the plugin is read at # (`scripts/deploy.sh dankserver --service jellyfin`), and the plugin is read at
@@ -340,6 +341,64 @@ cmd_install() {
fi 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() { cmd_verify() {
check_root check_root
local bad=0 local bad=0
@@ -378,6 +437,7 @@ cmd_verify() {
printf '%s %s chars\n' "$elem" "${#n}" printf '%s %s chars\n' "$elem" "${#n}"
fi fi
done done
verify_library_options || bad=1
[ "$bad" -eq 0 ] || return 1 [ "$bad" -eq 0 ] || return 1
printf 'providers configured\n' printf 'providers configured\n'
} }
+34 -4
View File
@@ -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. `/var/lib/media-meta/view:/data`, read-write.
`Local metadata` — save metadata to media folders — is enabled, and it is the `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 reason the view exists: `movie.nfo`, `tvshow.nfo` and `episode.nfo` written once
written once on the NVMe upper layer are files [[Plex]] can read too — and since on the NVMe upper layer are files [[Plex]] can read too — and since the
the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only metadata
metadata writer, and every sidecar it saves is an SSD byte the array never sees. 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 **Library coverage is the first thing to check when a title is missing.** The
four libraries are `movies → /data/Movies`, `tvshows → /data/TVShows`, 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 `scripts/media-view.sh status` says which: the quadlet drifted back to the raw
array, or the mount died. 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.
**`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 ## Ports & access
| Port | Proto | Purpose | | Port | Proto | Purpose |
+92
View File
@@ -24,6 +24,9 @@
# 9. no plugin configuration XML is tracked in git, the deployer places the # 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 # keys BEFORE it restarts Jellyfin, and the planner routes a change to this
# installer to jellyfin. # 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 load helpers
@@ -59,6 +62,32 @@ setup() {
TRASH="$BATS_TEST_TMPDIR/trash" TRASH="$BATS_TEST_TMPDIR/trash"
SEC="$BATS_TEST_TMPDIR/secrets" SEC="$BATS_TEST_TMPDIR/secrets"
mkdir -p "$JF/plugins/configurations" "$TRASH" "$SEC" 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" <<XML
<?xml version="1.0" encoding="utf-8"?>
<LibraryOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<EnableInternetProviders>true</EnableInternetProviders>
<MetadataSavers>
<string>Nfo</string>
</MetadataSavers>
<TypeOptions>
<TypeOptions>
<Type>Movie</Type>
<MetadataFetchers>
<string>TheMovieDb</string>
</MetadataFetchers>
<ImageFetchers>
<string>TheMovieDb</string>
</ImageFetchers>
<ImageFetcherOrder>
<string>TheMovieDb</string>
</ImageFetcherOrder>
</TypeOptions>
</TypeOptions>
</LibraryOptions>
XML
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml" <<XML cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml" <<XML
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
@@ -270,6 +299,69 @@ XML
printf '%s\n' "${lines[@]}" | grep -qE 'TmdbApiKey .*[0-9]+ chars' 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#<EnableInternetProviders>true</EnableInternetProviders>#<EnableInternetProviders>false</EnableInternetProviders>#' \
"$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" <<XML
<?xml version="1.0" encoding="utf-8"?>
<LibraryOptions>
<EnableInternetProviders>true</EnableInternetProviders>
<TypeOptions>
<TypeOptions>
<Type>Movie</Type>
<MetadataFetchers>
<string>TheMovieDb</string>
</MetadataFetchers>
<ImageFetchers />
<ImageFetcherOrder>
<string>TheMovieDb</string>
</ImageFetcherOrder>
</TypeOptions>
</TypeOptions>
</LibraryOptions>
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" <<XML
<?xml version="1.0" encoding="utf-8"?>
<LibraryOptions>
<TypeOptions>
<TypeOptions>
<Type>BoxSet</Type>
<MetadataFetchers>
<string>TheMovieDb</string>
</MetadataFetchers>
<ImageFetchers>
<string>TheMovieDb</string>
</ImageFetchers>
</TypeOptions>
</TypeOptions>
</LibraryOptions>
XML
run prov verify
[ "$status" -eq 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF '= Collections'
}
@test "no plugin configuration XML is tracked in this repo" { @test "no plugin configuration XML is tracked in this repo" {
# These files hold provider keys and are owned by the running container; # These files hold provider keys and are owned by the running container;
# tracking them would put credentials in git and guarantee drift. # tracking them would put credentials in git and guarantee drift.