Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536949c18d | ||
|
|
337afac91e | ||
|
|
c0c77590b1 | ||
|
|
441b908937 | ||
|
|
949d3cb6f4 | ||
|
|
3ce8d62e70 | ||
|
|
38737ab84e |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+391
@@ -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
|
||||
+107
-6
@@ -1,13 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Read-only census of a movie library — the inputs a relayout plan is built from.
|
||||
r"""Read-only census of a media library — the inputs a relayout plan is built from.
|
||||
|
||||
media-census.py enumerate [--root DIR] [--out TSV]
|
||||
media-census.py probe [--enum TSV] [--out TSV] [--jobs N]
|
||||
media-census.py plex [--root DIR] [--db PATH] [--out TSV]
|
||||
media-census.py jellyfin [--root DIR] [--db PATH] [--container PREFIX] [--out TSV]
|
||||
|
||||
Run it on the machine that mounts the library. Nothing here writes inside the
|
||||
library and nothing is deleted; the only outputs are the three TSVs below, and
|
||||
scripts/media-plan.py reads them instead of touching media.
|
||||
library and nothing is deleted; the only outputs are the TSVs below, and the
|
||||
scripts/media-plan.py and scripts/media-shows.py read them instead of touching media.
|
||||
|
||||
enum bytes <TAB> ext <TAB> path every file under the root
|
||||
probed bytes <TAB> path <TAB> dur <TAB> vcodec <TAB> WxH <TAB> fps <TAB> vbit
|
||||
@@ -18,10 +19,17 @@ scripts/media-plan.py reads them instead of touching media.
|
||||
plex file <TAB> leaf_title <TAB> … (see PLEX_COLS)
|
||||
the titles Plex already matched — the authority over any filename guess
|
||||
|
||||
jellyfin file <TAB> series <TAB> year <TAB> season <TAB> episode <TAB> extra
|
||||
<TAB> tmdb (see JF_COLS)
|
||||
one row per episode Jellyfin has matched and numbered, its path mapped
|
||||
from the container's view back to the host; `year` and `tmdb` are the
|
||||
SERIES' values, `extra` is Jellyfin's own extra type when the row is
|
||||
bonus material rather than an episode
|
||||
|
||||
`probe` is the slow step (every container is opened for its header, ~10 min for
|
||||
4,800 files on a spindle array at 6 workers) so it is `nice`d: a census beside a
|
||||
live service must not starve it. `enumerate` is a stat pass, so re-censusing
|
||||
before a plan is cheap — and a plan is only ever as good as these three files.
|
||||
before a plan is cheap — and a plan is only ever as good as these files.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
@@ -57,6 +65,14 @@ join metadata_items mi on mi.id = m.metadata_item_id
|
||||
left join metadata_items par on par.id = mi.parent_id
|
||||
where p.file like ? and p.deleted_at is null and mi.deleted_at is null
|
||||
"""
|
||||
JF_DB = "/home/dank/Containers/jellyfin/config/data/jellyfin.db"
|
||||
JF_COLS = ["file", "series", "year", "season", "episode", "extra", "tmdb"]
|
||||
# Jellyfin stores the entity's .NET class as its type; the TV tree is these three.
|
||||
JF_TV = ("MediaBrowser.Controller.Entities.TV.Episode",
|
||||
"MediaBrowser.Controller.Entities.TV.Season",
|
||||
"MediaBrowser.Controller.Entities.TV.Series")
|
||||
JF_SQL = ("""select Id, Path, IndexNumber, ParentIndexNumber, ExtraType, IsVirtualItem
|
||||
from BaseItems where Type = ? and Path like ?""")
|
||||
|
||||
|
||||
def enumerate_lib(root, out):
|
||||
@@ -165,7 +181,7 @@ def plex(root, db, out):
|
||||
"Plex runs rootless here: run this as the container's owner, never "
|
||||
"with sudo — rootless storage is per-user, so root sees nothing.")
|
||||
with open(out, "w", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t")
|
||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||
w.writerow(PLEX_COLS)
|
||||
n = 0
|
||||
for r in con.execute(PLEX_SQL, (root + "/%",)):
|
||||
@@ -174,6 +190,79 @@ def plex(root, db, out):
|
||||
print(f"plex-matched={n} -> {out}")
|
||||
|
||||
|
||||
def jellyfin(root, db, container, out):
|
||||
"""The episodes Jellyfin has already numbered, for the library at root.
|
||||
|
||||
Jellyfin stores each path as the CONTAINER sees it (its quadlet binds the
|
||||
share at /data), so `container` is the in-container prefix that stands for
|
||||
root and every exported path is mapped back to the host before it is written
|
||||
— the planner reads host paths, because it plans moves on the array.
|
||||
|
||||
The numbering is what matters, not the name: a release group's file name says
|
||||
nothing about which episode a file is, and Jellyfin has already answered that
|
||||
question against a provider. Season/episode come from the episode row; the
|
||||
series name, its year and its TMDb id come from the Series row the episode
|
||||
climbs to, because those are the only place Jellyfin keeps them.
|
||||
"""
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
except sqlite3.Error as e:
|
||||
sys.exit(f"cannot open the Jellyfin database read-only ({db}): {e}\n"
|
||||
"It is a WAL database: copy it with its -wal and -shm beside it and"
|
||||
" point --db at the copy. Never sudo it — the data dir belongs to"
|
||||
" the container's owner, and rootless storage is per-user.")
|
||||
have = {r[1] for r in con.execute("pragma table_info(BaseItems)")}
|
||||
need = {"Id", "ParentId", "Type", "Path", "IndexNumber", "ParentIndexNumber",
|
||||
"ExtraType", "IsVirtualItem", "ProductionYear", "Data"}
|
||||
if not need <= have:
|
||||
sys.exit(f"{db} is not a Jellyfin library database (BaseItems has no"
|
||||
f" {','.join(sorted(need - have))})")
|
||||
node = {}
|
||||
for i, pid, typ, name, year, data in con.execute(
|
||||
"select Id, ParentId, Type, Name, ProductionYear, Data from BaseItems"
|
||||
" where Type in (?,?,?)", JF_TV):
|
||||
node[i] = (pid, typ, name, year, data)
|
||||
|
||||
def series_of(item_id):
|
||||
"""Climb episode -> season -> series; empty when the chain is broken."""
|
||||
for _ in range(4):
|
||||
if item_id not in node:
|
||||
return "", "", ""
|
||||
pid, typ, name, year, data = node[item_id]
|
||||
if typ.endswith(".Series"):
|
||||
try:
|
||||
tmdb = (json.loads(data or "{}")
|
||||
.get("ProviderIds") or {}).get("Tmdb", "")
|
||||
except ValueError:
|
||||
tmdb = ""
|
||||
return name or "", year or "", tmdb
|
||||
item_id = pid
|
||||
return "", "", ""
|
||||
|
||||
# The fleet's quadlets bind the share at /data, so the container's view of
|
||||
# this library is /data/<the library's own folder name> unless told otherwise.
|
||||
if not container:
|
||||
container = "/data/" + os.path.basename(os.path.normpath(root))
|
||||
prefix = container.rstrip("/") + "/"
|
||||
written = skipped = 0
|
||||
with open(out, "w", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||
w.writerow(JF_COLS)
|
||||
for iid, path, idx, pidx, extra, virt in con.execute(JF_SQL,
|
||||
(JF_TV[0], prefix + "%")):
|
||||
if virt or not path or not path.startswith(prefix):
|
||||
skipped += 1 # a virtual item is a variant, not a file
|
||||
continue
|
||||
sname, syear, tmdb = series_of(iid)
|
||||
w.writerow([root + "/" + path[len(prefix):],
|
||||
sname.replace("\t", " "), syear,
|
||||
"" if pidx is None else pidx,
|
||||
"" if idx is None else idx,
|
||||
(extra or "").replace("\t", " "), tmdb])
|
||||
written += 1
|
||||
print(f"episodes={written} virtual-or-blank={skipped} -> {out}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
@@ -190,13 +279,25 @@ def main():
|
||||
x.add_argument("--root", **root)
|
||||
x.add_argument("--db", default=PLEX_DB)
|
||||
x.add_argument("--out", default="/tmp/plex-movies.tsv")
|
||||
j = sub.add_parser("jellyfin",
|
||||
help="export the episodes Jellyfin has numbered for the root")
|
||||
j.add_argument("--root", default=os.environ.get("MEDIA_ROOT",
|
||||
"/mnt/entropy/TVShows"),
|
||||
help="TV library root on the host")
|
||||
j.add_argument("--db", default=JF_DB)
|
||||
j.add_argument("--container", default=None,
|
||||
help="the in-container prefix that stands for --root"
|
||||
" (default /data/<root's folder name>)")
|
||||
j.add_argument("--out", default="/tmp/jellyfin-tv.tsv")
|
||||
a = ap.parse_args()
|
||||
if a.cmd == "enumerate":
|
||||
enumerate_lib(a.root, a.out)
|
||||
elif a.cmd == "probe":
|
||||
probe(a.enum, a.out, a.jobs)
|
||||
else:
|
||||
elif a.cmd == "plex":
|
||||
plex(a.root, a.db, a.out)
|
||||
else:
|
||||
jellyfin(a.root, a.db, a.container, a.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+705
@@ -0,0 +1,705 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Plan a Jellyfin-format relayout of a TV library. DRY RUN ONLY: nothing moves.
|
||||
|
||||
media-shows.py [--root DIR] [--enum TSV] [--titles TSV] [--probed TSV]
|
||||
[--tmdb-cache JSON] [--tmdb] [--movies DIR] [--park DIR]
|
||||
[--out TSV] [--exceptions TXT]
|
||||
|
||||
Inputs are the TSVs scripts/media-census.py exports: `enumerate` lists what
|
||||
exists, `jellyfin` says which episode each file IS, and `probe` is optional
|
||||
duration evidence for the movie rule. The TMDb cache is a plain JSON file so a
|
||||
plan stays reproducible: answers are read from it, and a miss is looked up only
|
||||
with --tmdb and TMDB_API_KEY in the environment (the key never enters argv, and
|
||||
never the cache).
|
||||
|
||||
Target contract (jellyfin.org/docs/general/server/media/shows):
|
||||
|
||||
<root>/<Series> (<year>)/Season <NN>/<Series> SxxEyy.ext
|
||||
one file holding two episodes SxxE01-E02 specials Season 00
|
||||
a documented part of an episode SxxE03 Part 1 sidecars <base>.<lang>.srt
|
||||
bonus material <series>/trailers|featurettes|… — never a season
|
||||
|
||||
PLAN is the TSV scripts/media-apply.py replays: `action src dst kind`, every
|
||||
mkdir row before any move, and `keep` for a file already where the contract puts
|
||||
it. EXCEPTIONS is the review list: everything parked, and why.
|
||||
|
||||
The authority chain, strongest first, for one video file:
|
||||
|
||||
1. a provider row — Jellyfin matched this exact path and numbered it.
|
||||
2. the file's own SxxEyy / 1x04 marker, the series taken from a sibling's
|
||||
provider row, else from the nearest ancestor folder that has one, else from
|
||||
the release folder's own words.
|
||||
3. nothing. An unnumbered file is PARKED under --park with its relative path
|
||||
kept: an invented `Season 01` steals an episode number from the file that
|
||||
really is S01E01, and a wrong number is worse than no number.
|
||||
|
||||
Guards, and why each is here:
|
||||
|
||||
* One series, one folder, one year: the year ANY provider row of a series
|
||||
states is shared by all of them, so a box set split across five release
|
||||
folders lands inside ONE series folder instead of five.
|
||||
* Two files claiming one episode: the bigger keeps the standard name, the other
|
||||
parks with the reason. Nothing is overwritten and nothing is deleted — which
|
||||
encode survives is Frank's call, and a parked file is one rename from home.
|
||||
* A file with no episode evidence that runs long and that the MOVIE provider
|
||||
names leaves the TV library for --movies under the movie naming contract.
|
||||
Long and unnamed parks; short and unnumbered is bonus material.
|
||||
* Series art and the show nfo follow the SERIES, a season image follows its
|
||||
SEASON, a subtitle or episode nfo follows the episode's new base — an episode
|
||||
moved without its subtitles is a half-move.
|
||||
* Hidden files, macOS forks, qBittorrent resume parts and the park pile itself
|
||||
are never moved. Skipping the park is what makes re-planning stable.
|
||||
|
||||
The plan crosses three trees (the library, its park, the movie library), so the
|
||||
executor runs with --root at the share that holds them, on its own journal —
|
||||
one journal is one undo:
|
||||
|
||||
python3 scripts/media-shows.py --root /mnt/entropy/TVShows \
|
||||
--enum /tmp/tv-enum.tsv --titles /tmp/jellyfin-tv.tsv \
|
||||
--out /mnt/entropy/media-tv-plan.tsv
|
||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/media-tv-relayout.jsonl
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ARGS = argparse.ArgumentParser(description=__doc__.splitlines()[0],
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ARGS.add_argument("--root", default=os.environ.get("TV_ROOT", "/mnt/entropy/TVShows"),
|
||||
help="the TV library the plan reorganises")
|
||||
ARGS.add_argument("--enum", default="/tmp/tv-enum.tsv",
|
||||
help="file census (media-census.py enumerate)")
|
||||
ARGS.add_argument("--titles", default="/tmp/jellyfin-tv.tsv",
|
||||
help="the provider's numbering (media-census.py jellyfin)")
|
||||
ARGS.add_argument("--probed", default="",
|
||||
help="ffprobe census for the movie rule; absent = no durations")
|
||||
ARGS.add_argument("--tmdb-cache", default="/tmp/tmdb-cache.json",
|
||||
help="TMDb answers as an input file, so a plan is reproducible")
|
||||
ARGS.add_argument("--tmdb", action="store_true",
|
||||
help="look up cache misses (needs TMDB_API_KEY)")
|
||||
ARGS.add_argument("--movies", default="/mnt/entropy/Movies",
|
||||
help="movie library a mis-filed film moves into")
|
||||
ARGS.add_argument("--park", default="",
|
||||
help="review pile (default: <root>/_unsorted)")
|
||||
ARGS.add_argument("--out", default="/tmp/tv-plan.tsv")
|
||||
ARGS.add_argument("--exceptions", default="/tmp/tv-exceptions.txt")
|
||||
ARGS = ARGS.parse_args()
|
||||
|
||||
ROOT = os.path.abspath(ARGS.root)
|
||||
PARK = os.path.abspath(ARGS.park or os.path.join(ROOT, "_unsorted"))
|
||||
MOVIES = os.path.abspath(ARGS.movies)
|
||||
ENGINE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media-titles.py")
|
||||
_spec = importlib.util.spec_from_file_location("eng", ENGINE)
|
||||
eng = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(eng)
|
||||
|
||||
try:
|
||||
CACHE = json.load(open(ARGS.tmdb_cache))
|
||||
except (OSError, ValueError):
|
||||
CACHE = {}
|
||||
API_KEY = os.environ.get("TMDB_API_KEY", "")
|
||||
|
||||
VIDEO_EXT = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".ts", ".m2ts", ".mpg",
|
||||
".mpeg", ".webm", ".wmv", ".divx", ".ogm", ".flv", ".vob", ".rm",
|
||||
".rmvb", ".iso", ".img"}
|
||||
SUB_EXT = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
|
||||
IMG_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn"}
|
||||
NFO_EXT = {".nfo", ".xml"}
|
||||
# The container's own word, for a sidecar named "episode.mkv.srt": that component
|
||||
# is not a language flag, and both matchers read the plain form better.
|
||||
CONTAINER = {e[1:] for e in VIDEO_EXT}
|
||||
# Files that are never anybody's business: hidden, forks, resume state, thumbnails.
|
||||
SKIP_NAME = re.compile(r"^(\.|#|dl_state|@eaDir|Thumbs\.db|desktop\.ini)|\.!q$")
|
||||
# Show-level metadata keeps its own name at the series root; season-level at the
|
||||
# season root; folder.jpg stays with the folder it was written for.
|
||||
SERIES_META = re.compile(r"^(tvshow|series|show)(\.nfo|\.xml)?$"
|
||||
r"|^(poster|tvposter|banner|clearlogo|clearart|logo|art"
|
||||
r"|landscape|fanart|showbackdrop|backdrop)\d*\."
|
||||
r"(jpg|jpeg|png|webp|bmp)$", re.I)
|
||||
SEASON_META = re.compile(r"^season\.nfo$", re.I)
|
||||
FOLDER_IMAGE = re.compile(r"^folder\.(jpg|jpeg|png|webp|bmp)$", re.I)
|
||||
SEASON_DIR_RE = re.compile(r"(?i)^\s*(?:season[\s._-]*(\d{1,3})|s(\d{1,2}))\s*$")
|
||||
BAD_CHARS = re.compile(r'[<>:"/|?*]')
|
||||
# SxxEyy with an optional second episode, plus the 1x04 form. The second number
|
||||
# is guarded so a resolution never reads as an episode: "S01E01-1080p" is ONE
|
||||
# episode, and the longest-match backtracking of \d{1,3} is what the (?![\dpP])
|
||||
# after it closes off.
|
||||
EP_RANGE = re.compile(r"(?i)(?<![a-z0-9])s(?:eason[\s._-]*)?(\d{1,2})[\s._-]*"
|
||||
r"e(?:p(?:isode)[\s._-]*)?(\d{1,3})"
|
||||
r"(?:[\s._-]*(?:-|to[\s._-]*|ep)[\s._-]*(?:s\d{1,2}[\s._-]*)?e?"
|
||||
r"(\d{1,3})(?![\dpP]))?")
|
||||
NX_RE = re.compile(r"(?i)(?<![a-z0-9])(\d{1,2})x(\d{2,3})(?![a-z0-9])")
|
||||
PART_RE = re.compile(r"(?i)(?:^|[\s._-])(?:part|pt|cd|dvd|disc|disk)[\s._-]?"
|
||||
r"([1-9]|i{1,3})(?:$|[\s._-])")
|
||||
# Jellyfin reads these subfolder names as bonus material. The planner emits only
|
||||
# these spellings, so 40k extras end up in nine folders instead of nine hundred.
|
||||
EXTRA_DIRS = [
|
||||
("trailers", r"trailer|teaser|promo|advert|commercial|tv[\s._-]spot|preview"),
|
||||
("deleted scenes", r"deleted"),
|
||||
("behind the scenes", r"behind[\s._-]the[\s._-]scenes|making[\s._-]of|bts"
|
||||
r"|b[\s._-]roll|gag[\s._-]reel|blooper|outtake|archival"
|
||||
r"|video[\s._-]archive|scene[\s._-]selection|menu|gallery"
|
||||
r"|stills?|visual[\s._-]effects|workprint"),
|
||||
("interviews", r"interview|cast[\s._-]and[\s._-]crew|audition|commentary"),
|
||||
("scene cuts", r"scene[\s._-]cuts?|scenecuts"),
|
||||
("featurettes", r"featurettes?"),
|
||||
("shorts", r"shorts?"),
|
||||
("clips", r"clips?"),
|
||||
]
|
||||
# Jellyfin's own ExtraType values, folded onto the same folder names.
|
||||
EXTRA_KIND = {"trailer": "trailers", "tmbd": "trailers", "intro": "other",
|
||||
"movie": "other", "scene": "scene cuts", "clip": "clips",
|
||||
"interview": "interviews", "behindthescenes": "behind the scenes",
|
||||
"deleted": "deleted scenes", "other": "other", "epilogue": "other",
|
||||
"opening": "other", "featurette": "featurettes", "short": "shorts"}
|
||||
SHORT_MAX = 45 * 60 # over this, a file with no episode evidence is film-shaped
|
||||
|
||||
|
||||
def rel(p):
|
||||
return p[len(ROOT) + 1:] if p.startswith(ROOT + "/") else p
|
||||
|
||||
|
||||
def clean(name):
|
||||
"""A name the filesystem and both matchers can read."""
|
||||
name = BAD_CHARS.sub("-", name).replace("\\", "-").strip(" .")
|
||||
return name[:180] or "untitled"
|
||||
|
||||
|
||||
def title_of(text):
|
||||
"""The engine's parser, applied to a name that has dots but no extension:
|
||||
splitext would read "The.Examples" as stem "The", so a sentinel extension is
|
||||
added and the parser sees an ordinary media name."""
|
||||
return eng.title_of(text + ".mkv")
|
||||
|
||||
|
||||
def markers(name):
|
||||
"""(season, [episodes], position) the episode markers a name states; None when
|
||||
it states none. The position is where the series' own words stop."""
|
||||
stem = os.path.splitext(name)[0]
|
||||
m = EP_RANGE.search(stem)
|
||||
if m:
|
||||
eps = [int(m.group(2))] + ([int(m.group(3))] if m.group(3) else [])
|
||||
return int(m.group(1)), eps, m.start()
|
||||
m = NX_RE.search(stem)
|
||||
if m:
|
||||
return int(m.group(1)), [int(m.group(2))], m.start()
|
||||
return None
|
||||
|
||||
|
||||
LEAD_IDX_RE = re.compile(r"^\s*(\d{1,3})\s*[-\u2013.\s]\s*[A-Za-z]")
|
||||
PACK_IDX_RE = re.compile(r"^\s*\d+[a-z]?[.)]\s+")
|
||||
|
||||
|
||||
def num(x):
|
||||
try:
|
||||
return int(x)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def folder_season(path):
|
||||
"""The season the FOLDER states (`Season 3`, `S3`). A human wrote it, and where
|
||||
a scraped row disagrees this is the one that survives a re-plan."""
|
||||
m = SEASON_DIR_RE.match(os.path.basename(os.path.dirname(path)))
|
||||
return int(next(g for g in m.groups() if g)) if m else None
|
||||
|
||||
|
||||
def leading_index(name):
|
||||
"""The number a file leads with inside a season folder — `01 Kyle MacLachlan,
|
||||
Sinead OC.avi` is episode 1. Both matchers read it that way."""
|
||||
m = LEAD_IDX_RE.match(os.path.splitext(name)[0])
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def tmdb(kind, query):
|
||||
"""A provider answer for a query: the cache first, TMDb once, then never again."""
|
||||
ck = f"{kind}:{query}"
|
||||
if ck in CACHE:
|
||||
hit = CACHE[ck]
|
||||
# A cache an operator edited by hand can carry the year as a number; the
|
||||
# comparison below is text, so the wire form is imposed on every hit.
|
||||
return None if hit is None else {**hit, "year": str(hit.get("year") or "")}
|
||||
if not (ARGS.tmdb and API_KEY):
|
||||
return None
|
||||
path = "movie" if kind == "movie" else "tv"
|
||||
url = ("https://api.themoviedb.org/3/search/" + path + "?query="
|
||||
+ urllib.parse.quote(query) + "&include_adult=false")
|
||||
req = urllib.request.Request(url, headers={
|
||||
# The key rides a header, never the URL: a token in argv is world-readable.
|
||||
"Authorization": "Bearer " + API_KEY,
|
||||
"Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
found = json.load(r).get("results") or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"tmdb {kind} lookup failed for {query!r}: {e}", file=sys.stderr)
|
||||
return None
|
||||
if not found:
|
||||
CACHE[ck] = None
|
||||
return None
|
||||
top = found[0]
|
||||
CACHE[ck] = {"name": (top.get("title") or top.get("name") or "").strip(),
|
||||
"year": str(top.get("year") or "") or (top.get("first_air_date") or "")[:4],
|
||||
"id": top.get("id")}
|
||||
return CACHE[ck]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the inputs
|
||||
rows = []
|
||||
for line in open(ARGS.enum):
|
||||
size, ext, path = line.rstrip("\n").split("\t")
|
||||
p = os.path.abspath(path)
|
||||
base = os.path.basename(p)
|
||||
if not p.startswith(ROOT + "/") or SKIP_NAME.search(base):
|
||||
continue
|
||||
if p.startswith(PARK + "/"):
|
||||
continue # the park is already reviewed; re-parking is churn
|
||||
rows.append({"bytes": int(size), "ext": ext.lower(), "path": p, "base": base})
|
||||
held = {r["path"]: r for r in rows}
|
||||
|
||||
titles = {}
|
||||
with open(ARGS.titles) as fh:
|
||||
cols = fh.readline().rstrip("\n").split("\t")
|
||||
for line in fh:
|
||||
f = line.rstrip("\n").split("\t")
|
||||
f += [""] * (len(cols) - len(f))
|
||||
row = dict(zip(cols, f))
|
||||
p = os.path.abspath(row.get("file", ""))
|
||||
if p in held:
|
||||
titles[p] = row
|
||||
|
||||
dur = {}
|
||||
if ARGS.probed and os.path.exists(ARGS.probed):
|
||||
for line in open(ARGS.probed):
|
||||
f = line.rstrip("\n").split("\t")
|
||||
if len(f) > 2:
|
||||
try:
|
||||
dur[os.path.abspath(f[1])] = float(f[2] or 0)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
video = [r for r in rows if r["ext"] in VIDEO_EXT]
|
||||
other = [r for r in rows if r["ext"] not in VIDEO_EXT]
|
||||
|
||||
# What a folder already knows: one provider row names the series for every sibling
|
||||
# that has no row of its own — that is how an anime pack gets its show.
|
||||
by_dir = collections.defaultdict(list)
|
||||
for r in video:
|
||||
by_dir[os.path.dirname(r["path"])].append(r)
|
||||
dir_series = {}
|
||||
for d, rs in by_dir.items():
|
||||
names = collections.Counter(titles[r["path"]]["series"] for r in rs
|
||||
if r["path"] in titles and titles[r["path"]]["series"])
|
||||
if names:
|
||||
dir_series[d] = names.most_common(1)[0][0]
|
||||
|
||||
|
||||
# The registry: every series a provider names, judged before any folder is. Folder
|
||||
# words may resolve a file to one of these names; they may never invent one. That is
|
||||
# the whole difference between "NCOP 01.mkv" inside the Gurren Lagann pack, which the
|
||||
# pack names, and a new root folder called "01 NCOP".
|
||||
registry = {}
|
||||
for _p, _t in titles.items():
|
||||
if _t["series"]:
|
||||
registry.setdefault(eng.norm(_t["series"]), _t["series"])
|
||||
|
||||
|
||||
def known_in(text):
|
||||
"""The registered series named inside this text: the longest registered name
|
||||
whose words appear in it as a run. "(CBB) Tengen Toppa Gurren Lagann - The
|
||||
Complete Series (BD 1080p)" names "Tengen Toppa Gurren Lagann"."""
|
||||
hay = " " + eng.norm(text) + " "
|
||||
best = ""
|
||||
for k, name in registry.items():
|
||||
if len(k) >= 5 and f" {k} " in hay and len(k) > len(best):
|
||||
best = name
|
||||
return best
|
||||
|
||||
|
||||
# Rows also name their own folders' ancestors, so an already-filed library stays
|
||||
# where it is when it is re-planned.
|
||||
for d, name in sorted(dir_series.items()):
|
||||
a = os.path.dirname(d)
|
||||
while a and a != ROOT and len(a) > len(ROOT):
|
||||
if a not in dir_series:
|
||||
got = known_in(os.path.basename(a))
|
||||
if got:
|
||||
dir_series[a] = got
|
||||
a = os.path.dirname(a)
|
||||
|
||||
|
||||
def series_of_dir(d):
|
||||
"""The series an ancestor folder belongs to, nearest first: the folder holding
|
||||
provider rows, else a folder whose own words name a registered show."""
|
||||
while d and d != ROOT and len(d) > len(ROOT):
|
||||
if d in dir_series:
|
||||
return dir_series[d]
|
||||
got = known_in(os.path.basename(d))
|
||||
if got:
|
||||
return got
|
||||
d = os.path.dirname(d)
|
||||
return ""
|
||||
|
||||
|
||||
def extras_kind(path, extra_type):
|
||||
"""The canonical bonus subfolder, from Jellyfin's own extra type first."""
|
||||
if extra_type:
|
||||
return EXTRA_KIND.get(eng.norm(extra_type).replace(" ", ""), "other")
|
||||
for d in rel(path).split("/")[:-1]:
|
||||
if eng.folder_is_bonus(d):
|
||||
for name, rx in EXTRA_DIRS:
|
||||
if re.search(rx, d, re.I):
|
||||
return name
|
||||
return "extras"
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
for name, rx in EXTRA_DIRS:
|
||||
if re.search(rx, stem, re.I):
|
||||
return name
|
||||
return "extras"
|
||||
|
||||
|
||||
# ----------------------------------------------------------- classify every file
|
||||
series = {} # norm(name) -> {name, year, tmdb}
|
||||
claimants = [] # (row, name, season, [episodes])
|
||||
extras = [] # (row, name, bonus folder)
|
||||
films = [] # film-shaped, no episode evidence
|
||||
unknown = [] # (row, reason)
|
||||
|
||||
|
||||
def series_name(name):
|
||||
"""The provider's name as a folder: the pack index a scene release leads with
|
||||
("4a. Seed DESTINY") falls away, and the doubled spaces it leaves fold."""
|
||||
return clean(re.sub(r"\s{2,}", " ", PACK_IDX_RE.sub("", name)).strip(" ."))
|
||||
|
||||
|
||||
def note(name, year="", tmdb_id=""):
|
||||
"""Register a series; the first name wins, the first year and id stick."""
|
||||
k = eng.norm(name)
|
||||
s = series.setdefault(k, {"name": series_name(name), "year": "", "tmdb": ""})
|
||||
if year and not s["year"]:
|
||||
s["year"] = str(year)
|
||||
if tmdb_id and not s["tmdb"]:
|
||||
s["tmdb"] = str(tmdb_id)
|
||||
|
||||
|
||||
|
||||
def as_extra(r, owner, extra_type):
|
||||
"""Bonus material needs a series to file under; with none it is parked."""
|
||||
if not owner:
|
||||
unknown.append((r, "bonus material with no series to own it"))
|
||||
return
|
||||
note(owner)
|
||||
extras.append((r, owner, extras_kind(r["path"], extra_type)))
|
||||
|
||||
for r in video:
|
||||
p, t = r["path"], titles.get(r["path"])
|
||||
marked = markers(r["base"])
|
||||
fs = folder_season(p)
|
||||
if t and t["extra"]:
|
||||
as_extra(r, t["series"] or series_of_dir(os.path.dirname(p)), t["extra"])
|
||||
continue
|
||||
row = bool(t and t["series"] and (t["season"] or t["episode"]))
|
||||
if marked or row:
|
||||
# The provider row names the SHOW: its registered name, its year. The number
|
||||
# that orders the file comes from the file's own name first, then the folder
|
||||
# it sits in, then the row — scraped pack rows number episodes badly, where
|
||||
# every file under SNL 16-20/Season 16..19 carries season 16 episode 20.
|
||||
owner = (t["series"] if t and t["series"]
|
||||
else series_of_dir(os.path.dirname(p))
|
||||
or title_of(r["base"][:marked[2]])[0])
|
||||
if not eng.norm(owner).replace(" ", ""):
|
||||
unknown.append((r, "an episode marker with no series name to hang it on"))
|
||||
continue
|
||||
if marked:
|
||||
season, eps = marked[0], marked[1]
|
||||
else:
|
||||
season = fs if fs is not None else num(t["season"])
|
||||
ep = leading_index(r["base"]) or num(t["episode"])
|
||||
if not 0 < season <= 200 or ep <= 0:
|
||||
unknown.append((r, f"a provider row of season {season} episode {ep}"))
|
||||
continue
|
||||
eps = [ep]
|
||||
claimants.append((r, owner, season, eps))
|
||||
note(owner, t["year"] if t else "", t["tmdb"] if t else "")
|
||||
continue
|
||||
owner = series_of_dir(os.path.dirname(p))
|
||||
if fs is not None and owner and leading_index(r["base"]):
|
||||
claimants.append((r, owner, fs, [leading_index(r["base"])]))
|
||||
note(owner)
|
||||
continue
|
||||
if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT):
|
||||
as_extra(r, owner, "")
|
||||
continue
|
||||
if dur.get(p, 0.0) >= SHORT_MAX or (p not in dur and title_of(r["base"])[1] != "?"):
|
||||
films.append(r)
|
||||
continue
|
||||
unknown.append((r, "no episode marker, no provider row, nothing long enough to be a film"))
|
||||
|
||||
# A provider that never states a year still has one: the search is exact, so a
|
||||
# miss parks nothing — the folder simply carries no year, which Jellyfin accepts.
|
||||
for k, s in series.items():
|
||||
if not s["year"]:
|
||||
got = tmdb("tv", s["name"])
|
||||
if got and eng.norm(got["name"]) == k:
|
||||
s["year"] = got["year"]
|
||||
|
||||
if ARGS.tmdb:
|
||||
with open(ARGS.tmdb_cache, "w") as fh:
|
||||
json.dump(CACHE, fh, indent=1, sort_keys=True)
|
||||
|
||||
|
||||
def series_dir(name):
|
||||
s = series.get(eng.norm(name))
|
||||
if not s:
|
||||
s = {"name": clean(name), "year": ""}
|
||||
label = f"{s['name']} ({s['year']})" if s["year"] else s["name"]
|
||||
return f"{ROOT}/{clean(label)}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the plan
|
||||
moves, keeps, parked = [], [], []
|
||||
reasons = collections.defaultdict(list)
|
||||
used = {} # dst -> the file that claimed it
|
||||
|
||||
|
||||
def book(r, dst_dir, name, kind):
|
||||
"""Claim one destination for one file, or park the file when it is taken."""
|
||||
dst = f"{dst_dir}/{name}"
|
||||
if dst == r["path"]:
|
||||
keeps.append((r["path"], dst, kind))
|
||||
return
|
||||
if dst in used:
|
||||
park(r, "something else in the plan already files there")
|
||||
return
|
||||
if dst in held:
|
||||
park(r, f"{rel(dst)} already occupies that name")
|
||||
return
|
||||
used[dst] = r["path"]
|
||||
moves.append((r["path"], dst, kind))
|
||||
|
||||
|
||||
def park(r, reason):
|
||||
parked.append((r, reason))
|
||||
reasons[reason].append(r)
|
||||
dst = f"{PARK}/{rel(r['path'])}"
|
||||
if dst in used or dst in held:
|
||||
dst = f"{PARK}/{rel(r['path'])}.{len(reasons)}"
|
||||
used[dst] = r["path"]
|
||||
moves.append((r["path"], dst, "park"))
|
||||
|
||||
|
||||
# Episodes first: two files claiming one number is a decision Frank makes, not one
|
||||
# the plan sneaks — the bigger copy takes the standard name, the other waits.
|
||||
by_ep = collections.defaultdict(list)
|
||||
for r, name, season, eps in claimants:
|
||||
for e in eps:
|
||||
by_ep[(eng.norm(name), season, e)].append(r)
|
||||
winners, losers = set(), {}
|
||||
for (key, season, e), rs in by_ep.items():
|
||||
rs.sort(key=lambda r: (-r["bytes"], r["path"]))
|
||||
winners.add(rs[0]["path"])
|
||||
for r in rs[1:]:
|
||||
losers.setdefault(r["path"],
|
||||
f"duplicate of {series[key]['name']} S{season:02d}E{e:02d}"
|
||||
" (the bigger copy keeps the standard name)")
|
||||
for r, name, season, eps in claimants:
|
||||
if r["path"] in losers:
|
||||
park(r, losers[r["path"]])
|
||||
continue
|
||||
s = series[eng.norm(name)]
|
||||
label = f" S{season:02d}E" + "-E".join(f"{e:02d}" for e in eps)
|
||||
part = PART_RE.search(os.path.splitext(r["base"])[0])
|
||||
tail = f" Part {part.group(1).upper()}" if part and len(eps) == 1 else ""
|
||||
book(r, f"{series_dir(name)}/Season {season:02d}", s["name"] + label + tail + r["ext"],
|
||||
"tv")
|
||||
|
||||
for r, name, kind in extras:
|
||||
book(r, f"{series_dir(name)}/{kind}",
|
||||
clean(os.path.splitext(r["base"])[0]) + r["ext"], "extra")
|
||||
|
||||
# A film inside the TV library leaves for the movie library only when the movie
|
||||
# provider answers with a title that matches the name and, when the name states a
|
||||
# year, that year too.
|
||||
for r in films:
|
||||
parsed, year = title_of(r["base"])
|
||||
got = tmdb("movie", parsed)
|
||||
words = eng.norm(got["name"]).split() if got else []
|
||||
if not got or not eng.mentions(parsed.split(), words) \
|
||||
or (year != "?" and got["year"] and got["year"] != year):
|
||||
park(r, "long file with no episode evidence, and no movie provider names it")
|
||||
continue
|
||||
label = f"{got['name']} ({got['year']})" if got["year"] else got["name"]
|
||||
book(r, f"{MOVIES}/{clean(label)}", clean(label) + r["ext"], "movie")
|
||||
|
||||
# ------------------------------------------------- sidecars and folder metadata
|
||||
moved = {} # src -> dst, for every booked file
|
||||
for src, dst, _kind in moves:
|
||||
moved[src] = dst
|
||||
for src, dst, _kind in keeps:
|
||||
moved[src] = dst
|
||||
dst_dirs = collections.defaultdict(collections.Counter)
|
||||
video_dst = collections.defaultdict(list)
|
||||
for src, dst in moved.items():
|
||||
if src in {r["path"] for r in video}:
|
||||
video_dst[os.path.dirname(src)].append((src, dst))
|
||||
dst_dirs[os.path.dirname(src)][os.path.dirname(dst)] += 1
|
||||
|
||||
|
||||
def series_name_at(d):
|
||||
"""The series the files of a folder belong to — a registered name, or nothing.
|
||||
Folder words alone never name a show."""
|
||||
names = [titles[src]["series"] for src, _ in video_dst.get(d, [])
|
||||
if src in titles and titles[src]["series"]]
|
||||
if names:
|
||||
return collections.Counter(names).most_common(1)[0][0]
|
||||
return series_of_dir(d)
|
||||
|
||||
|
||||
def season_at(d):
|
||||
"""The season number a folder carries: its own name, else its files' rows."""
|
||||
m = SEASON_DIR_RE.match(os.path.basename(d))
|
||||
if m:
|
||||
return int(m.group(1) or m.group(2))
|
||||
nums = [int(titles[src]["season"]) for src, _ in video_dst.get(d, [])
|
||||
if src in titles and titles[src]["season"]]
|
||||
return collections.Counter(nums).most_common(1)[0][0] if nums else None
|
||||
|
||||
|
||||
def folder_dst(d):
|
||||
"""Where a folder's own images go: the one place its files landed, or the
|
||||
series root when they scattered across several seasons."""
|
||||
c = dst_dirs.get(d)
|
||||
if not c:
|
||||
return None
|
||||
if len(c) == 1:
|
||||
return c.most_common(1)[0][0]
|
||||
owner = series_name_at(d)
|
||||
return series_dir(owner) if owner else None
|
||||
|
||||
|
||||
def anchor_of(path, stem):
|
||||
"""The video file this sidecar names: the longest file name it extends, in the
|
||||
NEAREST folder that holds one. A scene pack buries its subtitles two levels
|
||||
down (`Pack/Subs/<release-name>/<release>.jpn.srt`), so the search climbs to
|
||||
the library root instead of stopping at the parent."""
|
||||
d = os.path.dirname(path)
|
||||
while len(d) >= len(ROOT) and d.startswith(ROOT):
|
||||
best = None
|
||||
for src, dst in video_dst.get(d, []):
|
||||
base = os.path.basename(src)
|
||||
for prefix in (os.path.splitext(base)[0], base):
|
||||
if stem == prefix or stem.startswith(prefix + "."):
|
||||
if best is None or len(prefix) > best[2]:
|
||||
best = (src, dst, len(prefix))
|
||||
if best:
|
||||
return best[0], best[1]
|
||||
up = os.path.dirname(d)
|
||||
if up == d:
|
||||
break
|
||||
d = up
|
||||
return None
|
||||
|
||||
|
||||
def sidecar_name(r, src, dst):
|
||||
"""The sidecar's own tail — language, flag, the media extension an nfo must
|
||||
carry — hung on the video file's new base."""
|
||||
stem = os.path.splitext(os.path.basename(src))[0]
|
||||
tail = os.path.splitext(r["base"])[0][len(stem):]
|
||||
# "Show.S01E01.mkv.srt" carries the container's name, not a flag: Jellyfin and
|
||||
# Plex both read "Show S01E01.srt", so the container word is dropped here.
|
||||
parts = tail.split(".")
|
||||
if len(parts) > 1 and parts[1].lower() in CONTAINER:
|
||||
tail = "".join("." + p for p in parts[2:])
|
||||
if r["ext"] in NFO_EXT:
|
||||
tail = os.path.splitext(os.path.basename(src))[1] + tail
|
||||
return clean(os.path.splitext(os.path.basename(dst))[0] + tail + r["ext"])
|
||||
|
||||
|
||||
for r in other:
|
||||
p, base = r["path"], r["base"]
|
||||
stem = os.path.splitext(base)[0]
|
||||
d = os.path.dirname(p)
|
||||
if r["ext"] in SUB_EXT or r["ext"] in NFO_EXT or r["ext"] in IMG_EXT:
|
||||
a = anchor_of(p, stem)
|
||||
if a:
|
||||
book(r, os.path.dirname(a[1]), sidecar_name(r, a[0], a[1]),
|
||||
"subtitle" if r["ext"] in SUB_EXT else
|
||||
("nfo" if r["ext"] in NFO_EXT else "image"))
|
||||
continue
|
||||
if SERIES_META.match(base):
|
||||
owner = series_name_at(d)
|
||||
if owner:
|
||||
book(r, series_dir(owner), base, "meta")
|
||||
continue
|
||||
elif SEASON_META.match(base):
|
||||
owner, season = series_name_at(d), season_at(d)
|
||||
if owner and season is not None:
|
||||
book(r, f"{series_dir(owner)}/Season {season:02d}", base, "meta")
|
||||
continue
|
||||
elif r["ext"] in IMG_EXT or r["ext"] in NFO_EXT:
|
||||
dest = folder_dst(d)
|
||||
if dest:
|
||||
book(r, dest, base, "meta")
|
||||
continue
|
||||
unknown.append((r, "not media, or media with nothing beside it to belong to"))
|
||||
|
||||
for r, reason in unknown:
|
||||
park(r, reason)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- output
|
||||
def write_plan():
|
||||
"""mkdir rows for every destination folder and its whole chain, then the moves.
|
||||
Parents first: the executor creates directories in the order they are listed."""
|
||||
chain = set()
|
||||
for _s, dst, _k in moves:
|
||||
d = os.path.dirname(dst)
|
||||
while d and d not in (ROOT, MOVIES, PARK, os.path.dirname(d)):
|
||||
chain.add(d)
|
||||
d = os.path.dirname(d)
|
||||
dirs = sorted(chain, key=lambda p: (p.count("/"), p))
|
||||
with open(ARGS.out, "w") as fh:
|
||||
fh.write("action\tsrc\tdst\tkind\n")
|
||||
for d in dirs:
|
||||
fh.write(f"mkdir\t\t{d}\tdir\n")
|
||||
for src, dst, kind in sorted(moves, key=lambda m: (m[1], m[0])):
|
||||
fh.write(f"move\t{src}\t{dst}\t{kind}\n")
|
||||
for src, dst, kind in sorted(keeps, key=lambda m: (m[1], m[0])):
|
||||
fh.write(f"keep\t{src}\t{dst}\t{kind}\n")
|
||||
sized = sum(held[s]["bytes"] for s, _d, _k in moves if s in held)
|
||||
print(f"plan: {len(moves) - len(parked)} moves, {len(parked)} parked"
|
||||
f" ({sized / 2**40:.2f} TiB touched), {len(keeps)} already placed,"
|
||||
f" {len(dirs)} folders -> {ARGS.out}")
|
||||
|
||||
|
||||
def write_exceptions():
|
||||
no_year = sorted(s["name"] for s in series.values() if not s["year"])
|
||||
with open(ARGS.exceptions, "w") as fh:
|
||||
fh.write(f"TV relayout exceptions for {ROOT}\n\n")
|
||||
fh.write(f"Everything below is parked under {PARK}, keeping the relative path it\n")
|
||||
fh.write("has today. Nothing is deleted and nothing is overwritten: a parked file\n")
|
||||
fh.write("is one rename from wherever the plan is pointed next.\n\n")
|
||||
for reason in sorted(reasons, key=lambda r: -len(reasons[r])):
|
||||
rs = reasons[reason]
|
||||
fh.write(f"{len(rs):>6} files {reason}\n")
|
||||
for r in sorted(rs, key=lambda r: rel(r["path"]))[:400]:
|
||||
fh.write(f" {rel(r['path'])}\n")
|
||||
if len(rs) > 400:
|
||||
fh.write(f" … and {len(rs) - 400} more\n")
|
||||
fh.write("\n")
|
||||
if no_year:
|
||||
fh.write(f"{len(no_year):>6} series carry no year from any provider, so their\n")
|
||||
fh.write(" folders are named without one; Jellyfin still matches them.\n")
|
||||
fh.write(" " + ", ".join(no_year[:300]) + "\n")
|
||||
print(f"exceptions -> {ARGS.exceptions}")
|
||||
|
||||
|
||||
write_plan()
|
||||
write_exceptions()
|
||||
@@ -275,7 +275,14 @@ def is_extra(path, title="", dur=0.0, root=ROOT, matched=False):
|
||||
bonus_folder = False
|
||||
for folder in parts[head:-1]:
|
||||
if folder_is_bonus(folder):
|
||||
bonus_folder = not mentions(name_words(name), name_words(folder))
|
||||
# The folder's words are often the film's own name ("Angel's Egg
|
||||
# Extras/"), and then they are not bonus evidence. Only WORDS can name
|
||||
# a film: "extras" is release junk that name_words drops, so a folder
|
||||
# like "01. Extras" reduces to its index — and an index matching an
|
||||
# index ("NCOP 01") is a coincidence, not a title.
|
||||
nw = [w for w in name_words(name) if not w.isdigit()]
|
||||
fw = [w for w in name_words(folder) if not w.isdigit()]
|
||||
bonus_folder = not fw or not mentions(nw, fw)
|
||||
break
|
||||
if dur and dur > SHORT_MAX:
|
||||
# Runtime vetoes vocabulary, with ONE exception: an explicit bonus folder
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
---
|
||||
type: reference
|
||||
title: Media Relayout
|
||||
description: "Census, plan and apply the movie library into Jellyfin format, and move TV out of it: the gates, refusals, rollback."
|
||||
description: "Census, plan and apply the movie and TV libraries into Jellyfin format: gates, evidence chain, refusals, rollback."
|
||||
tags: [dankserver, media-server, jellyfin, plex, procedure]
|
||||
timestamp: 2026-10-30T00:00:00Z
|
||||
---
|
||||
@@ -12,8 +12,8 @@ The movie library on [[Entropy Volume]] arrived as release folders —
|
||||
`Some.Movie.2019.1080p.WiKiTaLx.x265-FOO/` with the video inside named after
|
||||
the release. [[Jellyfin]] matches a movie by folder and file name, so the
|
||||
release noise cost posters, extras and versions. These scripts turn that
|
||||
library into Jellyfin's own shape — and move TV back out of it — and nothing
|
||||
else in the repo touches a media file.
|
||||
library into Jellyfin's own shape — the movies, the TV packs, and the TV that
|
||||
turns up in the movie share — and nothing else in the repo touches a media file.
|
||||
|
||||
| Script | Touches media? | Output |
|
||||
|---|---|---|
|
||||
@@ -21,8 +21,9 @@ else in the repo touches a media file.
|
||||
| `scripts/media-plan.py` | no — TSVs in, TSVs out | the plan + the exception report |
|
||||
| `scripts/media-apply.py` | **renames, never copies**; sweeps only what a run emptied | the moves, plus a journal |
|
||||
| `scripts/media-tv.py` | no — TSV out | a plan that moves TV into the TV library |
|
||||
| `scripts/media-shows.py` | no — TSV out | a plan that re-files the TV library itself |
|
||||
|
||||
The seam is `tests/media_relayout.bats`; the target contract is quoted from
|
||||
The seams are `tests/media_relayout.bats` and `tests/media_shows.bats`; the target contract is quoted from
|
||||
[jellyfin.org/docs/general/server/media/movies](https://jellyfin.org/docs/general/server/media/movies)
|
||||
in `media-plan.py`'s docstring.
|
||||
|
||||
@@ -153,6 +154,66 @@ folder it owns, never from the title whose bonus folder it happens to be parked
|
||||
in — `The Cleveland Show S03E07` inside `Die Hard (1988)/extras/` is not a 1988
|
||||
show. A name whose marker is `Part 2` is a film and stays put.
|
||||
|
||||
## The TV library's own relayout
|
||||
|
||||
`/mnt/entropy/TVShows` is scene packs rather than a library, and Jellyfin has
|
||||
already matched and numbered most of it. `scripts/media-shows.py` re-files that
|
||||
library into the same shape, and it never guesses: every series name and every
|
||||
episode number it writes traces to an authority, and what has none is parked for
|
||||
a human to read.
|
||||
|
||||
```bash
|
||||
# 1. The authority: Jellyfin's own matched rows (the DB opens read-only).
|
||||
python3 scripts/media-census.py jellyfin --root /mnt/entropy/TVShows \
|
||||
--out /tmp/tv_titles.tsv
|
||||
# 2. What is on disk (stat only), and the runtimes the film arm needs.
|
||||
python3 scripts/media-census.py enumerate --root /mnt/entropy/TVShows \
|
||||
--out /tmp/tv_enum.tsv
|
||||
python3 scripts/media-census.py probe --root /mnt/entropy/TVShows \
|
||||
--enum /tmp/tv_enum.tsv --jobs 6 --out /tmp/tv_probed.tsv
|
||||
# The probe is the slow step: ffprobe reads both ends of every file.
|
||||
# 3. The plan. --tmdb needs TMDB_API_KEY; without it a series no provider dated
|
||||
# is simply named without a year, which Jellyfin accepts.
|
||||
python3 scripts/media-shows.py --root /mnt/entropy/TVShows --enum /tmp/tv_enum.tsv \
|
||||
--titles /tmp/tv_titles.tsv --probed /tmp/tv_probed.tsv \
|
||||
--out /tmp/tv_plan.tsv --exceptions /tmp/tv_exceptions.txt
|
||||
# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same
|
||||
# executor, journal and rollback as the movie relayout above.
|
||||
scripts/media-apply.py apply --plan /tmp/tv_plan.tsv --root /mnt/entropy \
|
||||
--journal /mnt/entropy/media-relayout-tv.jsonl --apply
|
||||
```
|
||||
|
||||
Evidence, strongest first — the order the planner believes things:
|
||||
|
||||
- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`);
|
||||
- the **`Season NN` folder it sits in and the number its name leads with inside
|
||||
it**: `Season 19/01 Charles Barkley, Nirvana.avi` is S19E01;
|
||||
- the **provider row**: identity (the series name and its year), and the
|
||||
numbering of a file whose own name says nothing;
|
||||
- a **bonus folder** anywhere above the file, or Jellyfin's `ExtraType` → the
|
||||
series' canonical extra folder;
|
||||
- a runtime over `SHORT_MAX` makes it film-shaped, and it leaves for `--movies`
|
||||
only when a movie provider names it — otherwise it parks.
|
||||
|
||||
Two refusals carry the design, and a dry run on the real library found both:
|
||||
|
||||
- **Folders resolve a series; they never invent one.** A folder's words may
|
||||
resolve a file to a series a provider has already named — `(CBB) Tengen Toppa
|
||||
Gurren Lagann - The Complete Series (BD 1080p)` names the registered *Tengen
|
||||
Toppa Gurren Lagann* — but a bonus subfolder is not a show. An early run made
|
||||
`…/03. Extra/01. NCOP/NCOP 01.mkv` into a root series called `01 NCOP`.
|
||||
- **A scraped row does not outrank the filesystem.** Jellyfin numbers
|
||||
multi-season packs badly: every file under `SNL 16-20/Season 16..19` carries
|
||||
season 16 episode 20, and one Gundam row says season 2004. The folder, then
|
||||
the file's own leading number, win; a row that still insists on season 1989
|
||||
parks its file instead of creating `Season 1989`.
|
||||
|
||||
The provider's name is copied verbatim, warts included: Jellyfin carries a Series
|
||||
named `Black Clover 001` (92 episodes, no year) because it matched a pack off a
|
||||
filename. Renaming a show is Jellyfin's work — a folder rename never touches its
|
||||
provider ids — so the mover keeps the name it is handed and strips only the pack
|
||||
index a release leads with (`4a. Seed DESTINY` → `Seed DESTINY`).
|
||||
|
||||
## Laws
|
||||
|
||||
- **A moved file is a 404 in [[Plex]] until its section is rescanned.** The
|
||||
|
||||
@@ -49,10 +49,10 @@ 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. |
|
||||
| [[Media Relayout]] | Census, plan and apply the movie and TV libraries into Jellyfin format: gates, evidence chain, refusals, rollback. |
|
||||
| [[Plex]] | Plex Media Server is a **containerized media server on Dankserver**: a podman |
|
||||
| [[redis]] | The Redis cache and task queue for RomM: quadlet unit, network alias topology, and the backup path. |
|
||||
| [[RomM]] | RomM (ROM Manager) is a self-hosted ROM library manager and player. |
|
||||
|
||||
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}" ]
|
||||
}
|
||||
Executable
+307
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: the TV-library relayout — scripts/media-census.py jellyfin (the metadata
|
||||
# authority export) and scripts/media-shows.py (the plan).
|
||||
#
|
||||
# The TV library is 52k files written in five different release layouts, and the
|
||||
# one thing that makes a rename of that size survivable is that the plan is
|
||||
# derived from what a metadata provider already matched, not from a guess at the
|
||||
# file name. So every test here states which authority a file had — name only,
|
||||
# provider only, both, neither — and asserts where the PLAN files it, never how
|
||||
# the planner decided. A fixture Jellyfin database stands in for the live one and
|
||||
# the TMDb cache is an input file, so no test touches the network.
|
||||
#
|
||||
# The two outcomes that cost a re-run of a 50k-file plan are a file filed as an
|
||||
# episode that is not one (it steals an episode number from its real owner) and a
|
||||
# move that lands on an existing file. Everything below is written to catch
|
||||
# exactly those, plus the gate that matters most: the plan must be replayable by
|
||||
# scripts/media-apply.py, whose preflight is the fleet's only overwrite guard.
|
||||
|
||||
load helpers
|
||||
|
||||
PLAN="$REPO_ROOT/scripts/media-shows.py"
|
||||
CENSUS="$REPO_ROOT/scripts/media-census.py"
|
||||
APPLY="$REPO_ROOT/scripts/media-apply.py"
|
||||
|
||||
# at PATH SIZE — a file with deterministic content.
|
||||
at() { mkdir -p "$(dirname "$1")"; head -c "$2" /dev/zero > "$1"; }
|
||||
|
||||
# title_row FILE SERIES YEAR SEASON EPISODE [EXTRA] — one row of the authority
|
||||
# export scripts/media-census.py jellyfin writes.
|
||||
title_row() {
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t\n' "$1" "$2" "$3" "${4:-}" "${5:-}" "${6:-}" \
|
||||
>> "$TITLES"
|
||||
}
|
||||
|
||||
# dst SRC — the destination the plan gives SRC, or nothing when it does not move.
|
||||
dst() {
|
||||
awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $3}' "$OUT"
|
||||
}
|
||||
|
||||
setup() {
|
||||
LIB="$BATS_TEST_TMPDIR/TVShows"
|
||||
PARK="$LIB/_unsorted"
|
||||
MOVIES="$BATS_TEST_TMPDIR/Movies"
|
||||
ENUM="$BATS_TEST_TMPDIR/enum.tsv"
|
||||
PROBED="$BATS_TEST_TMPDIR/probed.tsv"
|
||||
TITLES="$BATS_TEST_TMPDIR/titles.tsv"
|
||||
CACHE="$BATS_TEST_TMPDIR/tmdb.json"
|
||||
OUT="$BATS_TEST_TMPDIR/plan.tsv"
|
||||
EXC="$BATS_TEST_TMPDIR/exceptions.txt"
|
||||
: > "$PROBED"
|
||||
|
||||
SEASON="$LIB/The Examples (2010) Season 1 S01 (1080p BluRay x265 Silence)"
|
||||
# An episode its own name states, with a subtitle, a season image and a
|
||||
# trailer: four things that have to end up in four different places.
|
||||
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv" 9000
|
||||
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.en.srt" 200
|
||||
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv.forced.srt" 250
|
||||
# A scene pack buries its subtitles two levels down, in Subs/<release-name>/.
|
||||
at "$SEASON/Subs/The.Examples.S01E01.Pilot.1080p.x265/The.Examples.S01E01.Pilot.1080p.x265.jpn.srt" 220
|
||||
at "$SEASON/folder.jpg" 300
|
||||
at "$SEASON/Trailers/teaser.mkv" 1000
|
||||
at "$SEASON/tvshow.nfo" 400
|
||||
# An anime pack: no episode marker anywhere in its name, numbered by the
|
||||
# provider only. Its year is the one thing no authority states.
|
||||
ANIME="$LIB/Anime Pack [grp] (1080p HEVC)"
|
||||
at "$ANIME/[grp] Anime Pack - 05.mkv" 5000
|
||||
# Two releases claiming one episode, and a file holding two episodes.
|
||||
at "$LIB/Dupes/Show Two S01E01.mkv" 4000
|
||||
at "$LIB/Dupes/Show.Two.S01E01.720p.other.mkv" 7000
|
||||
at "$LIB/Dupes/Show.Two.S01E02-E03.720p.mkv" 7000
|
||||
# A file no authority numbers, and a long file the movie provider knows.
|
||||
at "$LIB/Orphan Tapes/clip001.mp4" 3000
|
||||
at "$LIB/Longform/Some Documentary Special (2015).mkv" 8000
|
||||
# A bonus folder inside a release pack whose own name is not the show's. The
|
||||
# pack's rows name the show; the bonus folder may never invent a series.
|
||||
at "$ANIME/01. Extras/NCOP 01.mkv" 1200
|
||||
# A season folder whose rows are stale: the database numbers every file of this
|
||||
# pack season 16 episode 20, four seasons' worth of them.
|
||||
at "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi" 6000
|
||||
# One episode-shaped file loose at the library root.
|
||||
at "$LIB/Loose.Show.S02E03.720p.WEB-DL.mkv" 4000
|
||||
|
||||
# The enum census lists every file; the planner reads that instead of walking
|
||||
# the tree, so the fixture writes the same three columns.
|
||||
python3 - "$LIB" "$ENUM" <<'PY'
|
||||
import os, sys
|
||||
root, out = sys.argv[1], sys.argv[2]
|
||||
with open(out, "w") as fh:
|
||||
for d, _, fs in os.walk(root):
|
||||
for f in fs:
|
||||
p = os.path.join(d, f)
|
||||
fh.write(f"{os.stat(p).st_size}\t{os.path.splitext(f)[1]}\t{p}\n")
|
||||
PY
|
||||
|
||||
printf 'file\tseries\tyear\tseason\tepisode\textra\ttmdb\n' > "$TITLES"
|
||||
printf '{}\n' > "$CACHE"
|
||||
title_row "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv" \
|
||||
"The Examples" 2010 1 1
|
||||
title_row "$ANIME/[grp] Anime Pack - 05.mkv" "Anime Pack" "" 1 5
|
||||
title_row "$LIB/Dupes/Show Two S01E01.mkv" "Show Two" 2016 1 1
|
||||
title_row "$LIB/Dupes/Show.Two.S01E01.720p.other.mkv" "Show Two" 2016 1 1
|
||||
title_row "$LIB/Dupes/Show.Two.S01E02-E03.720p.mkv" "Show Two" 2016 1 2
|
||||
title_row "$LIB/Loose.Show.S02E03.720p.WEB-DL.mkv" "Loose Show" 2019 2 3
|
||||
title_row "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi" \
|
||||
"SNL Pack" "" 16 20
|
||||
}
|
||||
|
||||
plan() {
|
||||
run python3 "$PLAN" --root "$LIB" --enum "$ENUM" --titles "$TITLES" \
|
||||
--probed "$PROBED" --tmdb-cache "$CACHE" --movies "$MOVIES" \
|
||||
--out "$OUT" --exceptions "$EXC"
|
||||
}
|
||||
|
||||
@test "the census exports the provider's episode numbering as a TSV" {
|
||||
DB="$BATS_TEST_TMPDIR/jellyfin.db"
|
||||
python3 - "$DB" "$SEASON" <<'PY'
|
||||
import os, sqlite3, sys, uuid
|
||||
db, season = sys.argv[1], sys.argv[2]
|
||||
c = sqlite3.connect(db)
|
||||
pre = "/data/TVShows/" + os.path.basename(season)
|
||||
c.execute("""create table BaseItems (Id, ParentId, Type, Name, Path, IndexNumber,
|
||||
ParentIndexNumber, ProductionYear, ExtraType,
|
||||
IsVirtualItem, Data)""")
|
||||
sid = str(uuid.uuid4())
|
||||
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(sid, None, "MediaBrowser.Controller.Entities.TV.Series", "The Examples",
|
||||
pre, None, None, 2010, None, 0,
|
||||
'{"ProviderIds":{"Tmdb":"1234"}}'))
|
||||
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), sid, "MediaBrowser.Controller.Entities.TV.Episode",
|
||||
"Pilot", pre + "/The.Examples.S01E01.Pilot.1080p.x265.mkv", 1, 1,
|
||||
None, None, 0, '{}'))
|
||||
c.commit()
|
||||
PY
|
||||
run python3 "$CENSUS" jellyfin --root "$LIB" --db "$DB" \
|
||||
--out "$BATS_TEST_TMPDIR/exp.tsv"
|
||||
[ "$status" -eq 0 ]
|
||||
run head -1 "$BATS_TEST_TMPDIR/exp.tsv"
|
||||
[ "${lines[0]}" = "file series year season episode extra tmdb" ]
|
||||
tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \
|
||||
| grep -q "^$SEASON/The\.Examples\.S01E01\.Pilot\.1080p\.x265\.mkv The Examples 2010 1 1 1234$"
|
||||
}
|
||||
|
||||
@test "an episode its own name states files as Show SxxEyy under Season NN" {
|
||||
plan
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(dst "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv")" \
|
||||
= "$LIB/The Examples (2010)/Season 01/The Examples S01E01.mkv" ]
|
||||
}
|
||||
|
||||
@test "a subtitle follows its episode and keeps its language flag" {
|
||||
plan
|
||||
[ "$(dst "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.en.srt")" \
|
||||
= "$LIB/The Examples (2010)/Season 01/The Examples S01E01.en.srt" ]
|
||||
}
|
||||
|
||||
@test "a subtitle two folders down inside Subs/<release-name>/ still follows its episode" {
|
||||
plan
|
||||
[ "$(dst "$SEASON/Subs/The.Examples.S01E01.Pilot.1080p.x265/The.Examples.S01E01.Pilot.1080p.x265.jpn.srt")" \
|
||||
= "$LIB/The Examples (2010)/Season 01/The Examples S01E01.jpn.srt" ]
|
||||
}
|
||||
|
||||
@test "a subtitle that carries the container's word in its own name still follows the episode" {
|
||||
plan
|
||||
[ "$(dst "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv.forced.srt")" \
|
||||
= "$LIB/The Examples (2010)/Season 01/The Examples S01E01.forced.srt" ]
|
||||
}
|
||||
|
||||
@test "a season image keeps its own name; a series nfo keeps the series folder" {
|
||||
plan
|
||||
[ "$(dst "$SEASON/folder.jpg")" = "$LIB/The Examples (2010)/Season 01/folder.jpg" ]
|
||||
[ "$(dst "$SEASON/tvshow.nfo")" = "$LIB/The Examples (2010)/tvshow.nfo" ]
|
||||
}
|
||||
|
||||
@test "bonus material files as an extra, never as an episode" {
|
||||
plan
|
||||
[ "$(dst "$SEASON/Trailers/teaser.mkv")" = "$LIB/The Examples (2010)/trailers/teaser.mkv" ]
|
||||
[ -z "$(awk -F '\t' '$3 ~ /Season .*teaser/ {print $3}' "$OUT")" ]
|
||||
}
|
||||
|
||||
@test "a file only the provider numbers files at the episode it is matched to" {
|
||||
plan
|
||||
[ "$(dst "$ANIME/[grp] Anime Pack - 05.mkv")" \
|
||||
= "$LIB/Anime Pack/Season 01/Anime Pack S01E05.mkv" ]
|
||||
}
|
||||
|
||||
@test "a bonus folder inside a release pack belongs to the pack's show, not to its own name" {
|
||||
plan
|
||||
[ "$(dst "$ANIME/01. Extras/NCOP 01.mkv")" = "$LIB/Anime Pack/extras/NCOP 01.mkv" ]
|
||||
[ -z "$(awk -F '\t' '$3 ~ /\/01\. Extras\// {print $3}' "$OUT")" ]
|
||||
}
|
||||
|
||||
@test "a Season folder outvotes the stale row its files carry" {
|
||||
plan
|
||||
[ "$(dst "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi")" \
|
||||
= "$LIB/SNL Pack/Season 19/SNL Pack S19E01.avi" ]
|
||||
}
|
||||
|
||||
|
||||
@test "one file holding two episodes keeps both numbers in its name" {
|
||||
plan
|
||||
[ "$(dst "$LIB/Dupes/Show.Two.S01E02-E03.720p.mkv")" \
|
||||
= "$LIB/Show Two (2016)/Season 01/Show Two S01E02-E03.mkv" ]
|
||||
}
|
||||
|
||||
@test "two files claiming one episode: one keeps the name, the other parks" {
|
||||
plan
|
||||
n=$(grep -c " $LIB/Show Two (2016)/Season 01/Show Two S01E01.mkv " "$OUT")
|
||||
[ "$n" -eq 1 ]
|
||||
run dst "$LIB/Dupes/Show Two S01E01.mkv"
|
||||
[[ "$output" == "$PARK/"* ]]
|
||||
run dst "$LIB/Dupes/Show.Two.S01E01.720p.other.mkv"
|
||||
[[ "$output" == "$LIB/Show Two (2016)/Season 01/"* ]]
|
||||
}
|
||||
|
||||
@test "a file no authority numbers is parked, never renamed into the library" {
|
||||
plan
|
||||
run dst "$LIB/Orphan Tapes/clip001.mp4"
|
||||
[[ "$output" == "$PARK/Orphan Tapes/clip001.mp4" ]]
|
||||
grep -q "clip001.mp4" "$EXC"
|
||||
}
|
||||
|
||||
@test "a long file the movie provider knows leaves the TV library for the movie one" {
|
||||
printf '8000\t%s\t7200\thevc\t1920x1080\t23.976\t4000000\tyuv420p10le\taac:eng\t0\t0\tmatroska,webm\n' \
|
||||
"$LIB/Longform/Some Documentary Special (2015).mkv" > "$PROBED"
|
||||
python3 -c 'import json, sys
|
||||
json.dump({"movie:some documentary":
|
||||
{"name": "Some Documentary Special", "year": 2015, "id": 55}},
|
||||
open(sys.argv[1], "w"))' "$CACHE"
|
||||
plan
|
||||
[ "$(dst "$LIB/Longform/Some Documentary Special (2015).mkv")" \
|
||||
= "$MOVIES/Some Documentary Special (2015)/Some Documentary Special (2015).mkv" ]
|
||||
}
|
||||
|
||||
@test "a long file no movie provider knows is parked, not moved into Movies" {
|
||||
printf '8000\t%s\t7200\thevc\t1920x1080\t23.976\t4000000\tyuv420p10le\taac:eng\t0\t0\tmatroska,webm\n' \
|
||||
"$LIB/Longform/Some Documentary Special (2015).mkv" > "$PROBED"
|
||||
plan
|
||||
run dst "$LIB/Longform/Some Documentary Special (2015).mkv"
|
||||
[[ "$output" == "$PARK/"* ]]
|
||||
}
|
||||
|
||||
@test "the plan creates every folder before the move that needs it" {
|
||||
plan
|
||||
python3 - "$OUT" "$LIB" "$MOVIES" "$PARK" <<'PY'
|
||||
import os, sys
|
||||
plan, known = sys.argv[1], set(sys.argv[2:])
|
||||
made, bad = set(), []
|
||||
for line in open(plan).read().splitlines()[1:]:
|
||||
action, _src, dest, _kind = line.split("\t")
|
||||
if action == "mkdir":
|
||||
made.add(dest)
|
||||
elif action == "move":
|
||||
d = os.path.dirname(dest)
|
||||
while d not in known and d != os.path.dirname(d):
|
||||
if d not in made:
|
||||
bad.append(f"{dest}: {d} was never made")
|
||||
d = os.path.dirname(d)
|
||||
print("\n".join(bad[:5]))
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
}
|
||||
|
||||
@test "no move lands outside the library or the movie library" {
|
||||
plan
|
||||
run awk -F '\t' -v l="$LIB" -v m="$MOVIES" \
|
||||
'$1 == "move" && $3 !~ "^" l "/" && $3 !~ "^" m "/" {print $3}' "$OUT"
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "the executor preflights the plan and no target is claimed twice" {
|
||||
plan
|
||||
run python3 "$APPLY" apply --plan "$OUT" --root "$BATS_TEST_TMPDIR" \
|
||||
--journal "$BATS_TEST_TMPDIR/journal.jsonl"
|
||||
[ "$status" -eq 0 ]
|
||||
dup=$(awk -F '\t' '$1 == "move" {print $3}' "$OUT" | sort | uniq -d)
|
||||
[ -z "$dup" ]
|
||||
}
|
||||
|
||||
@test "re-planning a library the plan already satisfies moves nothing" {
|
||||
plan
|
||||
# Applying the plan and re-censusing is exactly what the operator does next:
|
||||
# every authority file is keyed by path, so all three are replayed through the
|
||||
# plan the way a fresh census would see them. (The enum has no header; the
|
||||
# titles export does, and its header row is not a path so it survives.)
|
||||
python3 - "$OUT" "$ENUM" "$TITLES" "$PROBED" <<'PY'
|
||||
import os, sys
|
||||
plan = sys.argv[1]
|
||||
moves = {}
|
||||
for line in open(plan).read().splitlines()[1:]:
|
||||
action, src, dest, _kind = line.split("\t")
|
||||
if action == "move":
|
||||
moves[src] = dest
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
os.rename(src, dest)
|
||||
for path, col in ((sys.argv[2], 2), (sys.argv[3], 0), (sys.argv[4], 1)):
|
||||
if not os.path.getsize(path):
|
||||
continue
|
||||
rows = [line.rstrip("\n").split("\t") for line in open(path)]
|
||||
with open(path, "w") as fh:
|
||||
for f in rows:
|
||||
f[col] = moves.get(f[col], f[col])
|
||||
fh.write("\t".join(f) + "\n")
|
||||
PY
|
||||
plan
|
||||
[ -z "$(awk -F '\t' '$1 == "move" {print $2}' "$OUT")" ]
|
||||
}
|
||||
Reference in new issue
Block a user