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).
390 lines
15 KiB
Bash
Executable File
390 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bats
|
|
# Seam: scripts/jellyfin-providers.sh — the only mechanism that puts a
|
|
# metadata-provider key into Jellyfin's plugin configuration on Dankserver.
|
|
#
|
|
# Output vocabulary the tests depend on:
|
|
# "+ <action>" something this run will change
|
|
# "= <fact>" something already true, so nothing changes
|
|
#
|
|
# The contracts, in the order the seam tests them:
|
|
# 1. plan reports every action and never prints a key;
|
|
# 2. a secret file that is not mode 600 is refused by name and nothing is
|
|
# written — a key readable by other users is an error, not a warning;
|
|
# 3. a downloaded plugin artifact is accepted only on its pinned sha256, its
|
|
# official guid and its pinned version (a swapped or downgraded artifact
|
|
# is refused before anything lands in plugins/);
|
|
# 4. the TMDb key is written into the EXISTING config with every other
|
|
# setting preserved — Jellyfin owns that file, and clobbering it would
|
|
# silently reset country, language and adult settings;
|
|
# 5. a TVDB config the operator already saved through the UI is kept;
|
|
# 6. install is idempotent: a second run changes no byte;
|
|
# 7. whatever is replaced is copied to the trash first (fleet law: never
|
|
# overwrite without a recoverable copy);
|
|
# 8. verify answers whether the endpoint is configured, key LENGTH only;
|
|
# 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
|
|
|
|
PROV="$REPO_ROOT/scripts/jellyfin-providers.sh"
|
|
TMDB_KEY="tmdbk3a9Zq7Rtestkey"
|
|
TVDB_KEY="tvdbk8b2Wt4Ptestkey"
|
|
GUID_OFFICIAL="a677c0da-fac5-4cde-941a-7134223f14c8"
|
|
|
|
# make_plugin <file> <guid> <version> — a zip shaped like the official TheTVDB
|
|
# artifact (meta.json + its two DLLs), so extraction is exercised for real.
|
|
make_plugin() {
|
|
python3 - "$1" "$2" "$3" <<'PYEOF'
|
|
import json, sys, zipfile
|
|
path, guid, version = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
meta = {
|
|
"category": "MoviesAndShows",
|
|
"guid": guid,
|
|
"name": "TheTVDB",
|
|
"owner": "jellyfin",
|
|
"targetAbi": "12.0.0.0",
|
|
"version": version,
|
|
}
|
|
with zipfile.ZipFile(path, "w") as z:
|
|
z.writestr("meta.json", json.dumps(meta))
|
|
z.writestr("Jellyfin.Plugin.Tvdb.dll", "dll-bytes")
|
|
z.writestr("Tvdb.Sdk.dll", "dll-bytes")
|
|
PYEOF
|
|
sha256sum "$1" | cut -d' ' -f1
|
|
}
|
|
|
|
setup() {
|
|
JF="$BATS_TEST_TMPDIR/jf"
|
|
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">
|
|
<MetadataCountryCode>US</MetadataCountryCode>
|
|
<PreferOriginalLanguage />
|
|
<Language>en</Language>
|
|
<EnableCinematicForTrailers>false</EnableCinematicForTrailers>
|
|
<IncludeAdult>false</IncludeAdult>
|
|
<MaxCastMembers>25</MaxCastMembers>
|
|
<AddGenresFromKeywords>true</AddGenresFromKeywords>
|
|
<TmdbApiKey />
|
|
</Configuration>
|
|
XML
|
|
KEYS="$SEC/provider-keys"
|
|
printf 'TMDB_API_KEY=%s\nTVDB_API_KEY=%s\n' "$TMDB_KEY" "$TVDB_KEY" >"$KEYS"
|
|
chmod 600 "$KEYS"
|
|
ZIP="$BATS_TEST_TMPDIR/thetvdb.zip"
|
|
PIN_SHA="$(make_plugin "$ZIP" "$GUID_OFFICIAL" 24.0.0.0)"
|
|
PROV_ENV=(
|
|
JF_ROOT="$JF"
|
|
PROV_KEYS="$KEYS"
|
|
TRASH_DIR="$TRASH"
|
|
TVDB_PLUGIN_URL="file://$ZIP"
|
|
TVDB_PLUGIN_SHA256="$PIN_SHA"
|
|
TVDB_PLUGIN_GUID="$GUID_OFFICIAL"
|
|
TVDB_PLUGIN_VERSION=24.0.0.0
|
|
)
|
|
}
|
|
|
|
prov() { env "${PROV_ENV[@]}" bash "$PROV" "$@"; }
|
|
|
|
tree_fingerprint() { find "$JF" -type f | sort | xargs -r sha256sum | sha256sum; }
|
|
|
|
@test "plan names the plugin and both config files, and prints no key" {
|
|
run prov plan
|
|
[ "$status" -eq 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -q '^+ install thetvdb_24\.0\.0\.0'
|
|
printf '%s\n' "${lines[@]}" | grep -q '^+ set TmdbApiKey'
|
|
printf '%s\n' "${lines[@]}" | grep -q '^+ create Jellyfin\.Plugin\.Tvdb\.xml'
|
|
# The whole point of running this over a shoulder: a key never appears.
|
|
! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY"
|
|
! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY"
|
|
}
|
|
|
|
@test "plan writes nothing" {
|
|
before="$(tree_fingerprint)"
|
|
run prov plan
|
|
[ "$status" -eq 0 ]
|
|
[ "$(tree_fingerprint)" = "$before" ]
|
|
}
|
|
|
|
@test "an already-configured endpoint reports no pending action" {
|
|
prov install >/dev/null
|
|
run prov plan
|
|
[ "$status" -eq 0 ]
|
|
! printf '%s\n' "${lines[@]}" | grep -q '^+ '
|
|
printf '%s\n' "${lines[@]}" | grep -q '= thetvdb_24\.0\.0\.0 already installed'
|
|
printf '%s\n' "${lines[@]}" | grep -q '= TmdbApiKey already set'
|
|
printf '%s\n' "${lines[@]}" | grep -q '= Jellyfin\.Plugin\.Tvdb\.xml already has an ApiKey'
|
|
}
|
|
|
|
@test "a keys file that is not mode 600 is refused and nothing is written" {
|
|
chmod 644 "$KEYS"
|
|
before="$(tree_fingerprint)"
|
|
run prov install
|
|
[ "$status" -ne 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qF '600'
|
|
[ "$(tree_fingerprint)" = "$before" ]
|
|
}
|
|
|
|
@test "a missing TVDB key is refused by name before anything is fetched" {
|
|
printf 'TMDB_API_KEY=%s\n' "$TMDB_KEY" >"$KEYS"
|
|
chmod 600 "$KEYS"
|
|
run prov install
|
|
[ "$status" -ne 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qF 'TVDB_API_KEY'
|
|
[ ! -d "$JF/plugins/thetvdb_24.0.0.0" ]
|
|
}
|
|
|
|
@test "a root with no plugin directory is refused instead of fabricated" {
|
|
# Creating plugins/ inside a wrong path would install a plugin Jellyfin
|
|
# never loads, and would hide the operator's typo.
|
|
run env "${PROV_ENV[@]}" JF_ROOT="$BATS_TEST_TMPDIR/nope" bash "$PROV" install
|
|
[ "$status" -ne 0 ]
|
|
[ ! -d "$BATS_TEST_TMPDIR/nope/plugins" ]
|
|
}
|
|
|
|
@test "an artifact whose sha256 is not the pinned one is refused" {
|
|
run env "${PROV_ENV[@]}" TVDB_PLUGIN_SHA256="$(printf 'f%.0s' {1..64})" \
|
|
bash "$PROV" install
|
|
[ "$status" -ne 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qiF 'sha256'
|
|
[ ! -d "$JF/plugins/thetvdb_24.0.0.0" ]
|
|
}
|
|
|
|
@test "an artifact that is not the official TVDB plugin is refused" {
|
|
# A matching sha proves only that the bytes are the bytes we fetched.
|
|
EVIL="$BATS_TEST_TMPDIR/evil.zip"
|
|
EVIL_SHA="$(make_plugin "$EVIL" "deadbeef-0000-0000-0000-000000000000" 24.0.0.0)"
|
|
run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$EVIL" TVDB_PLUGIN_SHA256="$EVIL_SHA" \
|
|
bash "$PROV" install
|
|
[ "$status" -ne 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qiF 'guid'
|
|
[ ! -d "$JF/plugins/thetvdb_24.0.0.0" ]
|
|
}
|
|
|
|
@test "an artifact that is not the pinned version is refused" {
|
|
OLD="$BATS_TEST_TMPDIR/old.zip"
|
|
OLD_SHA="$(make_plugin "$OLD" "$GUID_OFFICIAL" 22.0.0.0)"
|
|
run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$OLD" TVDB_PLUGIN_SHA256="$OLD_SHA" \
|
|
bash "$PROV" install
|
|
[ "$status" -ne 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qiF 'version'
|
|
[ ! -d "$JF/plugins/thetvdb_24.0.0.0" ]
|
|
}
|
|
|
|
@test "a different plugin version already in plugins/ is refused, not doubled" {
|
|
# Two thetvdb_* directories means Jellyfin loads one plugin twice; the
|
|
# operator removes the old one (into the trash) deliberately, not by an
|
|
# installer quietly adding a second.
|
|
prov install >/dev/null
|
|
OLD="$BATS_TEST_TMPDIR/old.zip"
|
|
OLD_SHA="$(make_plugin "$OLD" "$GUID_OFFICIAL" 22.0.0.0)"
|
|
run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$OLD" TVDB_PLUGIN_SHA256="$OLD_SHA" \
|
|
TVDB_PLUGIN_VERSION=22.0.0.0 bash "$PROV" install
|
|
[ "$status" -ne 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qF 'thetvdb_24.0.0.0'
|
|
[ ! -d "$JF/plugins/thetvdb_22.0.0.0" ]
|
|
}
|
|
|
|
@test "install places the plugin, keys TMDb, and preserves every other setting" {
|
|
run prov install
|
|
[ "$status" -eq 0 ]
|
|
! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY"
|
|
! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY"
|
|
[ -f "$JF/plugins/thetvdb_24.0.0.0/meta.json" ]
|
|
[ -f "$JF/plugins/thetvdb_24.0.0.0/Jellyfin.Plugin.Tvdb.dll" ]
|
|
local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml"
|
|
# Jellyfin owns this file: the settings it wrote must survive the edit.
|
|
for setting in MetadataCountryCode Language IncludeAdult MaxCastMembers AddGenresFromKeywords; do
|
|
grep -qF "<$setting>" "$cfg" || {
|
|
echo "lost $setting"
|
|
return 1
|
|
}
|
|
done
|
|
grep -qF '<IncludeAdult>false</IncludeAdult>' "$cfg"
|
|
grep -qF "<TmdbApiKey>$TMDB_KEY</TmdbApiKey>" "$cfg"
|
|
grep -qF "<ApiKey>$TVDB_KEY</ApiKey>" "$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
|
|
}
|
|
|
|
@test "a TVDB config the operator saved through the UI is kept" {
|
|
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.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">
|
|
<ApiKey>ui-saved-key</ApiKey>
|
|
<EnableSeasonPathMatching>true</EnableSeasonPathMatching>
|
|
</Configuration>
|
|
XML
|
|
run prov install
|
|
[ "$status" -eq 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qiF 'kept'
|
|
local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
|
|
grep -qF '<ApiKey>ui-saved-key</ApiKey>' "$cfg"
|
|
grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg"
|
|
}
|
|
|
|
@test "a pin in the TVDB secret lands in the plugin config" {
|
|
# A subscriber-shaped key needs the account's PIN beside it; a licensed key
|
|
# does not, so the element appears only when the secret carries one.
|
|
printf 'TMDB_API_KEY=%s\nTVDB_API_KEY=%s\nTVDB_PIN=123456\n' "$TMDB_KEY" "$TVDB_KEY" >"$KEYS"
|
|
chmod 600 "$KEYS"
|
|
prov install >/dev/null
|
|
grep -qF '<Pin>123456</Pin>' "$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
|
|
}
|
|
|
|
@test "install is idempotent: a second run changes no byte" {
|
|
prov install >/dev/null
|
|
before="$(tree_fingerprint)"
|
|
run prov install
|
|
[ "$status" -eq 0 ]
|
|
[ "$(tree_fingerprint)" = "$before" ]
|
|
printf '%s\n' "${lines[@]}" | grep -qiF 'no change'
|
|
# One plugin directory only: two versions in plugins/ means two loads.
|
|
[ "$(find "$JF/plugins" -maxdepth 1 -type d -name 'thetvdb_*' | wc -l)" -eq 1 ]
|
|
}
|
|
|
|
@test "what install replaces is copied to the trash first" {
|
|
prov install >/dev/null
|
|
# The TMDb config existed before this tool touched it, so the pre-edit copy
|
|
# must stay recoverable.
|
|
[ -n "$(find "$TRASH" -name 'Jellyfin.Plugin.Tmdb.xml*' -print -quit)" ]
|
|
grep -qF '<TmdbApiKey />' "$(find "$TRASH" -name 'Jellyfin.Plugin.Tmdb.xml*' | head -1)"
|
|
}
|
|
|
|
@test "verify refuses an unconfigured endpoint and names what is missing" {
|
|
run prov verify
|
|
[ "$status" -ne 0 ]
|
|
printf '%s\n' "${lines[@]}" | grep -qiF 'thetvdb'
|
|
printf '%s\n' "${lines[@]}" | grep -qiF 'TmdbApiKey'
|
|
}
|
|
|
|
@test "verify passes once the plugin and both keys are in place" {
|
|
prov install >/dev/null
|
|
run prov verify
|
|
[ "$status" -eq 0 ]
|
|
! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY"
|
|
! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY"
|
|
# Length, not bytes: the operator sees the key loaded without reading it.
|
|
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.
|
|
run git -C "$REPO_ROOT" ls-files
|
|
[ "$status" -eq 0 ]
|
|
! printf '%s\n' "${lines[@]}" | grep -qF 'plugins/configurations'
|
|
! printf '%s\n' "${lines[@]}" | grep -qiF 'Plugin.Tvdb.xml'
|
|
}
|
|
|
|
@test "the planner routes this installer to jellyfin" {
|
|
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver \
|
|
--paths scripts/jellyfin-providers.sh
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "jellyfin" ]
|
|
}
|
|
|
|
@test "the deployer places the keys before it restarts jellyfin" {
|
|
# The plugin and both keys are read at start-up, so a restart that runs
|
|
# ahead of the placement boots the old, keyless state.
|
|
body="$(sed -n '/^deploy_jellyfin()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")"
|
|
[ -n "$body" ]
|
|
up="$(printf '%s\n' "$body" | grep -n 'ensure_provider_keys' | head -1 | cut -d: -f1)"
|
|
restart="$(printf '%s\n' "$body" | grep -n 'systemctl --user restart jellyfin.service' | head -1 | cut -d: -f1)"
|
|
[ "${up:-999}" -lt "${restart:-0}" ]
|
|
}
|