diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py
index 02b7985..6053151 100755
--- a/scripts/deploy-plan.py
+++ b/scripts/deploy-plan.py
@@ -476,6 +476,11 @@ def services_for_path(path: str, target: str) -> set[str]:
# The installer is what creates, mounts and reaps the view both media
# servers read, so a change to it is a restart of that pair.
_add(services, "jellyfin", "plex")
+ if name == "jellyfin-providers.sh":
+ # The deployer runs this before jellyfin restarts, so a change to
+ # the installer is a change to the provider state Jellyfin loads at
+ # start-up: it selects jellyfin and nothing else.
+ _add(services, "jellyfin")
if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}:
# llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve
# files here; each one selects frank-zealot only. The ik_llama.cpp
diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh
index d9547bc..bcb3825 100755
--- a/scripts/deploy-remote.sh
+++ b/scripts/deploy-remote.sh
@@ -1216,8 +1216,28 @@ ensure_media_view() {
bash scripts/media-view.sh verify
}
+ensure_provider_keys() {
+ # Jellyfin's metadata-provider keys live in the container-owned plugin
+ # configuration under the data root, never in git; scripts/jellyfin-providers.sh
+ # is what places them, reading the per-service host env file (the fleet
+ # convention: grafana, caddy, pihole, copyparty, romm, seerr) that holds
+ # TMDB_API_KEY= and TVDB_API_KEY=, mode 600.
+ # Missing keys mean a keyless provider — a degraded library, not a broken
+ # deploy — so this warns rather than failing the restart. But when the keys
+ # ARE placed it must happen before the restart: the plugin and its keys are
+ # read at start-up, so a restart first would boot the old state.
+ local keys=/home/dank/Containers/jellyfin/provider-keys
+ if [ -f "$keys" ]; then
+ bash scripts/jellyfin-providers.sh install
+ else
+ echo "WARN: $keys is absent - provider keys NOT placed"
+ echo "WARN: create it (TMDB_API_KEY=, TVDB_API_KEY=, chmod 600) and re-deploy jellyfin"
+ fi
+}
+
deploy_jellyfin() {
ensure_media_view
+ ensure_provider_keys
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
systemctl --user daemon-reload
if ! systemctl --user is-active -q jellyfin.service; then
diff --git a/scripts/jellyfin-providers.sh b/scripts/jellyfin-providers.sh
new file mode 100755
index 0000000..cc95ec2
--- /dev/null
+++ b/scripts/jellyfin-providers.sh
@@ -0,0 +1,391 @@
+#!/usr/bin/env bash
+# Metadata-provider keys for Jellyfin on Dankserver, placed from the service's
+# host env file with the plugin artifact pinned by content hash.
+#
+# Jellyfin 12 ships TMDb, OMDb, StudioImages and MusicBrainz as CORE plugins:
+# they have no DLL, only a configuration file under the data root, and that
+# file is created by the running container. So the fleet's provider state lives
+# in two places the repo does not track —
+#
+# plugins/configurations/Jellyfin.Plugin.Tmdb.xml (empty = keyless)
+# plugins/configurations/Jellyfin.Plugin.Tvdb.xml (needs the plugin)
+#
+# and both are owned by the container user. This tool is the only thing that
+# writes them: it is declarative, idempotent, and it never prints a key, so it
+# is safe to run while someone watches.
+#
+# Keys never enter git and never enter argv (a value in argv is world-readable
+# through /proc//cmdline). They live in the per-service host env file that
+# is the fleet's convention for container credentials (grafana, caddy, pihole,
+# copyparty, romm, seerr), mode 600:
+#
+# /home/dank/Containers/jellyfin/provider-keys
+# TMDB_API_KEY=
+# TVDB_API_KEY=
+# TVDB_PIN= # optional: only a subscriber-shaped TVDB key
+# # needs one; a licensed key does not
+#
+# TVDB itself is not core in 12 (the metadata provider was cut in 10.9), so it
+# is installed from the official catalog, pinned three ways: sha256 of the
+# artifact, the plugin's own guid, and its version. A matching sha proves only
+# that the bytes are the bytes fetched — the guid check is what says it is the
+# official plugin, the version check is what says it is the one this file was
+# reviewed against.
+#
+# 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
+#
+# Nothing here restarts Jellyfin: the deployer owns that
+# (`scripts/deploy.sh dankserver --service jellyfin`), and the plugin is read at
+# start-up, so a key written while the server runs is not live until then.
+set -euo pipefail
+
+# Jellyfin's data root on Dankserver (the rootless quadlet's config volume).
+JF_ROOT="${JF_ROOT:-/home/dank/Containers/jellyfin/config}"
+PROV_KEYS="${PROV_KEYS:-/home/dank/Containers/jellyfin/provider-keys}"
+TRASH_DIR="${TRASH_DIR:-$HOME/.AGENTTRASH/$(date +%Y%m%d)-jellyfin-providers}"
+
+PLUGINS="$JF_ROOT/plugins"
+CONF="$PLUGINS/configurations"
+TMDB_XML="$CONF/Jellyfin.Plugin.Tmdb.xml"
+TVDB_XML="$CONF/Jellyfin.Plugin.Tvdb.xml"
+
+# repo.jellyfin.org catalog entry for TheTVDB (owner: jellyfin), version 24.0.0.0,
+# targetAbi 12.0.0.0 — published 2026-09-09, verified against Jellyfin 12.4.0.
+TVDB_PLUGIN_URL="${TVDB_PLUGIN_URL:-https://repo.jellyfin.org/files/plugin/thetvdb/thetvdb_24.0.0.0.zip}"
+TVDB_PLUGIN_SHA256="${TVDB_PLUGIN_SHA256:-6302fba3857bdde8d186dd099fac09ca43247041f531a528a2c788add70a5319}"
+TVDB_PLUGIN_GUID="${TVDB_PLUGIN_GUID:-a677c0da-fac5-4cde-941a-7134223f14c8}"
+TVDB_PLUGIN_VERSION="${TVDB_PLUGIN_VERSION:-24.0.0.0}"
+PLUGIN_DIR="thetvdb_${TVDB_PLUGIN_VERSION}"
+TVDB_DLL="Jellyfin.Plugin.Tvdb.dll"
+
+die() {
+ printf 'jellyfin-providers: %s\n' "$*" >&2
+ exit 1
+}
+
+usage() {
+ printf 'usage: %s plan|install|verify\n' "$0" >&2
+ exit 2
+}
+
+# check_keys — refuse anything that is not a readable private file. An open
+# mode is an error, not a warning: a provider key readable by every user on the
+# box is the failure this check exists to catch.
+check_keys() {
+ [ -f "$PROV_KEYS" ] ||
+ die "$PROV_KEYS not found (create it with TMDB_API_KEY= and TVDB_API_KEY=, then chmod 600)"
+ local mode
+ mode="$(stat -c '%a' "$PROV_KEYS")"
+ case "$mode" in
+ 600 | 400) ;;
+ *) die "$PROV_KEYS is mode $mode; provider keys must be 600" ;;
+ esac
+}
+
+# keys_value — the value of one variable in the env file, or nothing.
+keys_value() {
+ awk -v k="$1" -F= '$1 == k { sub(/^[^=]*=/, ""); print; exit }' "$PROV_KEYS"
+}
+
+# keys_len — the length, and a refusal naming the variable when absent.
+# The value itself is never printed.
+keys_len() {
+ local v
+ v="$(keys_value "$1")"
+ [ -n "$v" ] || die "$PROV_KEYS has no $1= line"
+ printf '%s' "${#v}"
+}
+
+# xml_value — the element's text, empty when it is absent or
+# self-closing.
+xml_value() {
+ python3 - "$1" "$2" <<'PYEOF'
+import re, sys
+src = open(sys.argv[1], encoding="utf-8").read()
+m = re.search(r"<%s(?:\s*/>|>(.*?)%s>)" % (sys.argv[2], sys.argv[2]), src, re.S)
+print(m.group(1).strip() if (m and m.group(1)) else "")
+PYEOF
+}
+
+# xml_secret — set to that variable from the
+# env file. The python process reads the env file itself, so the value never
+# appears in argv.
+xml_secret() {
+ python3 - "$1" "$2" "$3" "$PROV_KEYS" <<'PYEOF'
+import os, re, sys, tempfile
+from xml.sax.saxutils import escape
+
+xml_path, elem, var, keys_path = sys.argv[1:5]
+val = ""
+for line in open(keys_path, encoding="utf-8"):
+ key, sep, rest = line.partition("=")
+ if sep and key.strip() == var:
+ val = rest.strip()
+ break
+if not val:
+ raise SystemExit(f"{keys_path} has no {var}= line")
+
+src = open(xml_path, encoding="utf-8").read()
+pat = re.compile(r"([ \t]*)<%s(?:\s*/>|>.*?%s>)" % (elem, elem), re.S)
+text = escape(val)
+if pat.search(src):
+ out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}{elem}>", src, count=1)
+else:
+ out = src.replace("", f" <{elem}>{text}{elem}>\n", 1)
+if out == src:
+ raise SystemExit(f"{xml_path}: no place to write <{elem}>")
+
+d, base = os.path.split(xml_path)
+fd, tmp = tempfile.mkstemp(prefix=base + ".", dir=d)
+with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ fh.write(out)
+os.chmod(tmp, os.stat(xml_path).st_mode & 0o777)
+os.replace(tmp, xml_path)
+PYEOF
+}
+
+# plugin_meta — "guid version" out of the artifact's own meta.json.
+plugin_meta() {
+ python3 - "$1" <<'PYEOF'
+import json, sys, zipfile
+meta = json.loads(zipfile.ZipFile(sys.argv[1]).read("meta.json").decode("utf-8"))
+print(meta.get("guid", ""), meta.get("version", ""))
+PYEOF
+}
+
+check_root() {
+ # Never fabricate a data root: creating plugins/ beside a typo'd path would
+ # install a plugin Jellyfin never loads and hide the operator's mistake.
+ [ -d "$CONF" ] || die "no Jellyfin configuration directory at $CONF (is JF_ROOT right?)"
+}
+
+check_keys_report() {
+ check_keys
+ # A refusal inside a command substitution used as a printf argument would
+ # be swallowed, so the guard runs as its own statement.
+ keys_len TMDB_API_KEY >/dev/null
+ keys_len TVDB_API_KEY >/dev/null
+ printf '= keys read from %s (mode %s)\n' "$PROV_KEYS" "$(stat -c '%a' "$PROV_KEYS")"
+}
+
+# The plugin's state, as one line in the shared vocabulary.
+plugin_state() {
+ if [ -d "$PLUGINS/$PLUGIN_DIR" ]; then
+ printf '= %s already installed\n' "$PLUGIN_DIR"
+ return 0
+ fi
+ local other
+ other="$(find "$PLUGINS" -maxdepth 1 -type d -name 'thetvdb_*' ! -name "$PLUGIN_DIR" -printf '%f\n' 2>/dev/null | sort | tr '\n' ' ')"
+ if [ -n "$other" ]; then
+ printf '+ install %s (a different version is present: %s)\n' "$PLUGIN_DIR" "$other"
+ return 0
+ fi
+ printf '+ install %s from %s\n' "$PLUGIN_DIR" "${TVDB_PLUGIN_URL#*://}"
+}
+
+tmdb_state() {
+ if [ ! -f "$TMDB_XML" ]; then
+ printf '! %s is missing — save the TMDb plugin settings once in the UI, then re-run\n' "$TMDB_XML" >&2
+ return 1
+ fi
+ local now want
+ now="$(xml_value "$TMDB_XML" TmdbApiKey)"
+ want="$(keys_value TMDB_API_KEY)"
+ if [ -n "$now" ] && [ "$now" = "$want" ]; then
+ printf '= TmdbApiKey already set (%s chars)\n' "${#now}"
+ elif [ -n "$now" ]; then
+ printf '+ replace the TmdbApiKey in Jellyfin.Plugin.Tmdb.xml\n'
+ else
+ printf '+ set TmdbApiKey in Jellyfin.Plugin.Tmdb.xml\n'
+ fi
+}
+
+tvdb_state() {
+ local now
+ if [ -f "$TVDB_XML" ]; then
+ now="$(xml_value "$TVDB_XML" ApiKey)"
+ if [ -n "$now" ]; then
+ printf '= Jellyfin.Plugin.Tvdb.xml already has an ApiKey (kept)\n'
+ else
+ printf '+ fill the empty ApiKey in Jellyfin.Plugin.Tvdb.xml\n'
+ fi
+ else
+ printf '+ create Jellyfin.Plugin.Tvdb.xml with ApiKey\n'
+ fi
+}
+
+write_plugin() {
+ local tmp guid version other
+ other="$(find "$PLUGINS" -maxdepth 1 -type d -name 'thetvdb_*' ! -name "$PLUGIN_DIR" -printf '%f\n' 2>/dev/null | sort | tr '\n' ' ')"
+ [ -z "$other" ] ||
+ die "another TheTVDB version is already installed ($other); move it to the trash first — two copies of one plugin means two loads"
+
+ tmp="$(mktemp "${TMPDIR:-/tmp}/thetvdb.XXXXXX.zip")"
+ # The pins are checked before anything is unpacked, so a bad artifact never
+ # reaches the plugins directory.
+ curl -fsSL --max-time 300 -o "$tmp" "$TVDB_PLUGIN_URL" ||
+ { rm -f "$tmp"; die "could not fetch $TVDB_PLUGIN_URL"; }
+ local got
+ got="$(sha256sum "$tmp" | cut -d' ' -f1)"
+ if [ "$got" != "$TVDB_PLUGIN_SHA256" ]; then
+ rm -f "$tmp"
+ die "artifact sha256 is $got, pinned $TVDB_PLUGIN_SHA256 — refusing to install"
+ fi
+ read -r guid version <<<"$(plugin_meta "$tmp")"
+ if [ "$guid" != "$TVDB_PLUGIN_GUID" ]; then
+ rm -f "$tmp"
+ die "artifact guid is $guid, expected the official $TVDB_PLUGIN_GUID — refusing to install"
+ fi
+ if [ "$version" != "$TVDB_PLUGIN_VERSION" ]; then
+ rm -f "$tmp"
+ die "artifact version is $version, pinned $TVDB_PLUGIN_VERSION — refusing to install"
+ fi
+ mkdir -p "$PLUGINS/$PLUGIN_DIR"
+ python3 -m zipfile -e "$tmp" "$PLUGINS/$PLUGIN_DIR/"
+ rm -f "$tmp"
+ [ -f "$PLUGINS/$PLUGIN_DIR/$TVDB_DLL" ] ||
+ die "$PLUGIN_DIR has no $TVDB_DLL — the artifact does not match this plugin"
+ printf '+ installed %s\n' "$PLUGIN_DIR"
+}
+
+backup_to_trash() {
+ local f="$1"
+ mkdir -p "$TRASH_DIR"
+ local dest="$TRASH_DIR/$(basename "$f").$(date +%H%M%S)"
+ cp -p "$f" "$dest"
+ printf '+ previous copy kept at %s\n' "$dest"
+}
+
+cmd_write_keys() {
+ local now want
+ KEYS_CHANGED=0
+ # TMDb: the running container owns this file and every setting in it. Only
+ # the key element is touched, so country, language and adult settings
+ # survive the edit.
+ [ -f "$TMDB_XML" ] ||
+ die "$TMDB_XML is missing — save the TMDb plugin settings once in the UI, then re-run"
+ now="$(xml_value "$TMDB_XML" TmdbApiKey)"
+ keys_len TMDB_API_KEY >/dev/null
+ want="$(keys_value TMDB_API_KEY)"
+ if [ "$now" = "$want" ]; then
+ printf '= TmdbApiKey already set (%s chars)\n' "${#now}"
+ else
+ backup_to_trash "$TMDB_XML"
+ xml_secret "$TMDB_XML" TmdbApiKey TMDB_API_KEY
+ printf '+ set TmdbApiKey (%s chars)\n' "${#want}"
+ KEYS_CHANGED=1
+ fi
+
+ # TVDB: never overwrite a config the operator saved through the UI.
+ if [ -f "$TVDB_XML" ]; then
+ now="$(xml_value "$TVDB_XML" ApiKey)"
+ if [ -n "$now" ]; then
+ printf '= Jellyfin.Plugin.Tvdb.xml already has an ApiKey (kept)\n'
+ else
+ keys_len TVDB_API_KEY >/dev/null
+ backup_to_trash "$TVDB_XML"
+ xml_secret "$TVDB_XML" ApiKey TVDB_API_KEY
+ if [ -n "$(keys_value TVDB_PIN)" ] && [ -z "$(xml_value "$TVDB_XML" Pin)" ]; then
+ xml_secret "$TVDB_XML" Pin TVDB_PIN
+ fi
+ printf '+ filled the ApiKey in Jellyfin.Plugin.Tvdb.xml\n'
+ KEYS_CHANGED=1
+ fi
+ else
+ keys_len TVDB_API_KEY >/dev/null
+ cat >"$TVDB_XML" <
+
+
+
+XML
+ xml_secret "$TVDB_XML" ApiKey TVDB_API_KEY
+ if [ -n "$(keys_value TVDB_PIN)" ]; then
+ xml_secret "$TVDB_XML" Pin TVDB_PIN
+ fi
+ printf '+ created Jellyfin.Plugin.Tvdb.xml with ApiKey\n'
+ KEYS_CHANGED=1
+ fi
+}
+
+cmd_plan() {
+ check_root
+ check_keys_report
+ printf 'root %s\n' "$JF_ROOT"
+ plugin_state
+ tmdb_state
+ tvdb_state
+}
+
+cmd_install() {
+ check_root
+ check_keys_report
+ KEYS_CHANGED=0
+ if [ ! -d "$PLUGINS/$PLUGIN_DIR" ]; then
+ write_plugin
+ KEYS_CHANGED=1
+ else
+ printf '= %s already installed\n' "$PLUGIN_DIR"
+ fi
+ cmd_write_keys
+ if [ "$KEYS_CHANGED" -eq 1 ]; then
+ # Nothing is restarted here on purpose: this host may be playing media
+ # right now, and both the plugin and its keys are read at start-up.
+ printf 'next: scripts/deploy.sh dankserver --service jellyfin (the plugin and keys load at start-up)\n'
+ else
+ printf '= no change\n'
+ fi
+}
+
+cmd_verify() {
+ check_root
+ local bad=0
+ if [ ! -d "$PLUGINS/$PLUGIN_DIR" ]; then
+ printf 'MISSING plugin directory %s (thetvdb not installed)\n' "$PLUGIN_DIR" >&2
+ bad=1
+ else
+ local dlls abi
+ dlls="$(find "$PLUGINS/$PLUGIN_DIR" -maxdepth 1 -name '*.dll' | wc -l)"
+ [ "$dlls" -ge 1 ] || {
+ printf 'MISSING %s inside %s\n' "$TVDB_DLL" "$PLUGIN_DIR" >&2
+ bad=1
+ }
+ abi="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("targetAbi","?"))' \
+ "$PLUGINS/$PLUGIN_DIR/meta.json" 2>/dev/null || echo '?')"
+ printf 'plugin %s: %s dll(s), targetAbi %s\n' "$PLUGIN_DIR" "$dlls" "$abi"
+ fi
+ local pair elem n
+ for pair in "$TMDB_XML|TmdbApiKey" "$TVDB_XML|ApiKey"; do
+ elem="${pair##*|}"
+ if [ ! -f "${pair%%|*}" ]; then
+ printf 'MISSING %s\n' "${pair%%|*}" >&2
+ bad=1
+ continue
+ fi
+ if ! python3 -c 'import sys, xml.dom.minidom; xml.dom.minidom.parse(sys.argv[1])' "${pair%%|*}"; then
+ printf 'UNPARSEABLE %s\n' "${pair%%|*}" >&2
+ bad=1
+ continue
+ fi
+ n="$(xml_value "${pair%%|*}" "$elem")"
+ if [ -z "$n" ]; then
+ printf 'MISSING key: %s has no %s value\n' "$(basename "${pair%%|*}")" "$elem" >&2
+ bad=1
+ else
+ printf '%s %s chars\n' "$elem" "${#n}"
+ fi
+ done
+ [ "$bad" -eq 0 ] || return 1
+ printf 'providers configured\n'
+}
+
+cmd="${1:-}"
+case "$cmd" in
+plan) cmd_plan ;;
+install) cmd_install ;;
+verify) cmd_verify ;;
+*) usage ;;
+esac
diff --git a/tests/jellyfin_providers.bats b/tests/jellyfin_providers.bats
new file mode 100755
index 0000000..50aab63
--- /dev/null
+++ b/tests/jellyfin_providers.bats
@@ -0,0 +1,297 @@
+#!/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:
+# "+ " something this run will change
+# "= " 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.
+
+load helpers
+
+PROV="$REPO_ROOT/scripts/jellyfin-providers.sh"
+TMDB_KEY="tmdbk3a9Zq7Rtestkey"
+TVDB_KEY="tvdbk8b2Wt4Ptestkey"
+GUID_OFFICIAL="a677c0da-fac5-4cde-941a-7134223f14c8"
+
+# make_plugin — 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"
+ cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml" <
+
+ US
+
+ en
+ false
+ false
+ 25
+ true
+
+
+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 'false' "$cfg"
+ grep -qF "$TMDB_KEY" "$cfg"
+ grep -qF "$TVDB_KEY" "$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" <
+
+ ui-saved-key
+ true
+
+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 'ui-saved-key' "$cfg"
+ grep -qF 'true' "$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 '123456' "$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 '' "$(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 "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}" ]
+}