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*/>|>(.*?))" % (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*/>|>.*?)" % (elem, elem), re.S) +text = escape(val) +if pat.search(src): + out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}", src, count=1) +else: + out = src.replace("", f" <{elem}>{text}\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}" ] +}