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.
This commit is contained in:
1 parent
38737ab84e
commit
3ce8d62e70
4 files changed
+713
No files matched your search
Executable
+297
@@ -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}" ]
|
||||
}
|
||||
Reference in new issue
Block a user