Compare commits

...
Author SHA1 Message Date
dank 1a1dbac268 Merge pull request 'Jellyfin metadata providers: keys ride a host file the deployer installs' (#98) from feat/jellyfin-provider-keys into main 2026-10-30 22:48:21 -07:00
Frank 949d3cb6f4 docs(jellyfin): one page owns the provider keys and where they live
The page carries the deploy-time shape: which four providers are core plugins
in Jellyfin 12, which one is not, the exact XML each key lives in, the host
file the keys come from and its 600 rule, the three-way pin on the TVDB
artifact with its rollback, and what the deployer does when the keys file is
absent. OMDb is recorded as deliberately unconfigured -- keyed by email,
1000 req/day, and a ToS that forbids storing what it returns. index.md's row
carries the same locator.
2026-10-30 22:47:49 -07:00
Frank 3ce8d62e70 feat(jellyfin): the provider keys ride a host file the deployer installs
Jellyfin 12 ships TMDB, TVDB, StudioImages and MusicBrainz as core plugins:
no DLL, only a configuration file under the data root, so the keys have to
reach that XML from somewhere that is not git. They live in the fleet's
per-service env-file convention -- /home/dank/Containers/jellyfin/provider-keys
(TMDB_API_KEY=, TVDB_API_KEY=, optional TVDB_PIN=, mode 600) -- and
scripts/jellyfin-providers.sh plan|install|verify reads only that file, prints
no key byte, and refuses a keys file that is not mode 600 or that has no line
for the provider about to be written. install is idempotent: it never touches
a key the UI already saved, backs up every XML it edits into
~/.AGENTTRASH/<date>-jellyfin-providers/ first, and only installs the TVDB
plugin when the pinned official zip carries the plugin guid.

The deployer runs install BEFORE it restarts jellyfin, because the plugin and
its keys are read at start-up and a restart-first deploy boots the old state.
A missing keys file warns instead of failing: a host without the keys still
deploys, degraded rather than broken. No plugin configuration XML is tracked.
2026-10-30 22:47:39 -07:00
dank 38737ab84e Merge pull request 'Media metadata view: Jellyfin writes, Plex reads, the array stays read-only' (#97) from feat/media-metadata-view into main 2026-10-30 22:18:27 -07:00
6 changed files with 770 additions and 2 deletions

No files matched your search

+5
View File
@@ -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
+20
View File
@@ -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
+391
View File
@@ -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 <TmdbApiKey /> (empty = keyless)
# plugins/configurations/Jellyfin.Plugin.Tvdb.xml <ApiKey /> (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/<pid>/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=<key>
# TVDB_API_KEY=<key>
# TVDB_PIN=<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 <VAR> — 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 <VAR> — 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 <file> <element> — 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 <file> <element> <var> — set <element> 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("</Configuration>", f" <{elem}>{text}</{elem}>\n</Configuration>", 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 <zip> — "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 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 />
</Configuration>
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
+56 -1
View File
@@ -1,7 +1,7 @@
---
type: reference
title: Jellyfin
description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view layout, ports, access, hardware-transcode status."
description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status."
tags: [jellyfin, dankserver, media-server]
timestamp: 2026-10-30T00:00:00Z
---
@@ -117,6 +117,61 @@ where the LDAP plugin runs. Verified 2026-09-25: `alex` authenticates over
`AuthenticateByName` (HTTP 200; full plugin flow logged — service bind,
search, `ldapUid`, user bind, token).
## Metadata providers and their keys (2026-10-30)
TMDb, OMDb, StudioImages and MusicBrainz are **core plugins** in Jellyfin 12 —
no DLL, just a configuration file the running container owns under the data
root. TVDB is not core (its metadata provider was removed in 10.9), so it is an
official-catalog plugin.
| Provider | Shape on disk | Key lives in |
|---|---|---|
| TMDb | `config/plugins/configurations/Jellyfin.Plugin.Tmdb.xml` → `<TmdbApiKey>` | `/home/dank/Containers/jellyfin/provider-keys` → `TMDB_API_KEY=` (mode 600) |
| TVDB | `config/plugins/thetvdb_24.0.0.0/` + `…Jellyfin.Plugin.Tvdb.xml` → `<ApiKey>` | the same file → `TVDB_API_KEY=` |
| OMDb | `…Jellyfin.Plugin.Omdb.xml` | **not configured** — its key is issued by email, 1 000 req/day, and its ToS forbids storing the returned data; ratings stay Jellyfin's own lookup |
**None of it is tracked in git.** Those XML files belong to the container and
hold credentials, and `tests/jellyfin_providers.bats` fails the suite if one is
ever committed. The keys themselves sit in the per-service host env file that is
the fleet's convention for container credentials (grafana, caddy, pihole,
copyparty, romm, seerr): `provider-keys`, mode 600, beside the data root.
`scripts/jellyfin-providers.sh` is the only thing that writes the XML:
```
scripts/jellyfin-providers.sh plan # what would change; prints no key
scripts/jellyfin-providers.sh install # plugin + keys, previous copy to trash
scripts/jellyfin-providers.sh verify # plugin + key LENGTHS, never key bytes
```
- **A key never enters git, argv, or the tool's output.** The installer reads
that 600-perm file itself (a value in argv is world-readable through
`/proc/<pid>/cmdline`) and refuses it when it is group- or world-readable —
an exposed key is an error, not a warning. A key that shows up in a transcript
or `ps` output has leaked: rotate it with the provider, rewrite
`provider-keys`, re-run `install`.
- **The TVDB artifact is pinned three ways**: sha256
`6302fba3857bdde8d186dd099fac09ca43247041f531a528a2c788add70a5319`, the
official plugin guid `a677c0da-fac5-4cde-941a-7134223f14c8`, and version
`24.0.0.0` (`targetAbi 12.0.0.0`, which fits the deployed 12.4.0). A sha
match alone only proves the bytes are the bytes fetched — the guid check is
what says it is the official plugin. Rollback: move `thetvdb_24.0.0.0/` into
the trash and revert the pin; a second `thetvdb_*` directory is a refusal,
never a quiet second copy.
- **The homelab TVDB key is a licensed key** (its JWT claims say
`community_supported: false`), so it needs **no PIN** — the optional
`TVDB_PIN=` line exists only for a subscriber-shaped key.
- **TVDB is installed and keyed but is not a metadata agent**: TMDb still
identifies everything. Enabling TVDB as a provider would re-fetch show
metadata across the library, so it is a deliberate, separate change.
- **The deployer places the keys before it restarts jellyfin**
(`ensure_provider_keys` in `scripts/deploy-remote.sh`) and warns instead of
failing when `provider-keys` is absent, so a host without it still deploys.
- **Keys and plugins are read at start-up**: after a manual `install` the
change is inert until `./scripts/deploy.sh dankserver --service jellyfin`.
- TMDb's terms are the reason this stays narrow: attribution required, no
caching beyond 6 months, no derivative works. Fine for a personal homelab,
and the reason no bulk enrichment pass runs against it.
## Configuration workflow
1. Create libraries pointing at `/data` folders (movies, shows, audio).
+1 -1
View File
@@ -49,7 +49,7 @@ is a locator: one row, one clause, no numbers.
| [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |
| [[Dankserver Stack]] | Service inventory and routing overview for the media and library host. |
| [[Entropy Volume]] | The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at |
| [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view layout, ports, access, hardware-transcode status. |
| [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status. |
| [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. |
| [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. |
| [[Media Relayout]] | Census, plan and apply the movie library into Jellyfin format, and move TV out of it: the gates, refusals, rollback. |
+297
View File
@@ -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:
# "+ <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.
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"
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 "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}" ]
}