Compare commits

...
Author SHA1 Message Date
Frank e9314ea1ee fix(jellyfin): anchor the provider-key write on the file's real root
TheTVDB writes its own configuration file and its serializer omits a
null-valued setting entirely, so a fresh install has no <ApiKey> element to
fill, under a root element called <PluginConfiguration>. xml_secret's
fallback insertion was hardcoded to </Configuration>, matched nothing, and
install refused with "no place to write <ApiKey>" — measured on Dankserver
2026-11-02, where the TVDB key has been present in the secret file since
October and has never been installable.

The anchor now comes from the document's own root, so both shapes Jellyfin
actually produces work (the core plugins' <Configuration> and a plugin's
<PluginConfiguration>), and the refusal is kept for a file with no root at
all. Tests: the plugin-written shape is red before this commit and green
after; every other setting in the file is asserted preserved.
2026-11-02 04:38:40 -08:00
2 changed files with 50 additions and 4 deletions

No files matched your search

+12 -1
View File
@@ -135,7 +135,18 @@ text = escape(val)
if pat.search(src): if pat.search(src):
out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1) out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1)
else: else:
out = src.replace("</Configuration>", f" <{elem}>{text}</{elem}>\n</Configuration>", 1) # The plugin owns this file and writes it under ITS OWN root element
# (TheTVDB's is <PluginConfiguration>), and its serializer omits a
# null-valued setting entirely — so the element being filled can be
# absent outright, and a hardcoded </Configuration> anchor then matches
# nothing. Anchor on the document's real root instead.
out = src
root = re.search(r"<([A-Za-z_][\w.:-]*)[^>]*>", src)
if root and ("</%s>" % root.group(1)) in src:
name = root.group(1)
out = src.replace(
"</%s>" % name, f" <{elem}>{text}</{elem}>\n</{name}>", 1
)
if out == src: if out == src:
raise SystemExit(f"{xml_path}: no place to write <{elem}>") raise SystemExit(f"{xml_path}: no place to write <{elem}>")
+38 -3
View File
@@ -13,9 +13,12 @@
# 3. a downloaded plugin artifact is accepted only on its pinned sha256, its # 3. a downloaded plugin artifact is accepted only on its pinned sha256, its
# official guid and its pinned version (a swapped or downgraded artifact # official guid and its pinned version (a swapped or downgraded artifact
# is refused before anything lands in plugins/); # is refused before anything lands in plugins/);
# 4. the TMDb key is written into the EXISTING config with every other # 4. a key is written into the EXISTING config with every other setting
# setting preserved — Jellyfin owns that file, and clobbering it would # preserved, under whatever root that file's owner chose — Jellyfin's core
# silently reset country, language and adult settings; # configs say `<Configuration>`, TheTVDB writes `<PluginConfiguration>` and
# omits a null setting outright, so the element to fill can be absent and
# the anchor has to come from the file; clobbering the file would silently
# reset country, language and adult settings;
# 5. a TVDB config the operator already saved through the UI is kept; # 5. a TVDB config the operator already saved through the UI is kept;
# 6. install is idempotent: a second run changes no byte; # 6. install is idempotent: a second run changes no byte;
# 7. whatever is replaced is copied to the trash first (fleet law: never # 7. whatever is replaced is copied to the trash first (fleet law: never
@@ -254,6 +257,38 @@ XML
grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg" grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg"
} }
@test "the key lands in a plugin-written config with no ApiKey element" {
# What TheTVDB actually writes on a fresh install: its own root element and
# no <ApiKey /> at all, because its serializer drops null settings. The
# installer used to anchor on </Configuration> and refuse this file.
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<SubscriberPIN />
<CacheDurationInHours>1</CacheDurationInHours>
<OriginalTitle>true</OriginalTitle>
</PluginConfiguration>
XML
run prov install
[ "$status" -eq 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'filled the ApiKey'
local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
grep -qF "<ApiKey>$TVDB_KEY</ApiKey>" "$cfg"
grep -qF '<CacheDurationInHours>1</CacheDurationInHours>' "$cfg"
grep -qF '<OriginalTitle>true</OriginalTitle>' "$cfg"
}
@test "a config file with no root element is refused by name" {
# The guard still has to fire when there is genuinely nowhere to write —
# a refusal naming the file beats inventing a document shape.
printf '<?xml version="1.0" encoding="utf-8"?>\n' \
>"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
run prov install
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qF 'Jellyfin.Plugin.Tvdb.xml'
printf '%s\n' "${lines[@]}" | grep -qiF 'no place to write'
}
@test "a pin in the TVDB secret lands in the plugin config" { @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 # 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. # does not, so the element appears only when the secret carries one.