#!/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