feat(media): per-episode TV duplicate census, and the Jellyfin library that indexed but fetched nothing #116
No files matched your search
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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/<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`,
|
||||
@@ -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/<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
|
||||
|
||||
| Port | Proto | Purpose |
|
||||
|
||||
@@ -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" <<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
|
||||
<?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">
|
||||
@@ -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#<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" {
|
||||
# These files hold provider keys and are owned by the running container;
|
||||
# tracking them would put credentials in git and guarantee drift.
|
||||
|
||||
Reference in new issue
Block a user