Compare commits

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

The deployer runs install BEFORE it restarts jellyfin, because the plugin and
its keys are read at start-up and a restart-first deploy boots the old state.
A missing keys file warns instead of failing: a host without the keys still
deploys, degraded rather than broken. No plugin configuration XML is tracked.
2026-10-30 22:47:39 -07:00
dank 38737ab84e Merge pull request 'Media metadata view: Jellyfin writes, Plex reads, the array stays read-only' (#97) from feat/media-metadata-view into main 2026-10-30 22:18:27 -07:00
Frank 4437519430 chore(media): retire the legacy Jellyfin block from dankserver/compose.yaml
The container has had one owner since the Quadlet cutover
(dankserver/jellyfin/quadlet/jellyfin.container). The block was a second owner,
and wrong on every path: jellyfin/jellyfin:latest instead of the pinned ghcr
image, /opt/jellyfin/* data roots that no longer exist, and a read-only
/mnt/entropy:/data bind that the metadata view has since replaced.

The planner no longer counts jellyfin as a compose service, so editing the
inventory cannot restart a media server; tests/deployment_scoping.bats pins that
contract for both jellyfin and plex.
2026-10-30 22:17:04 -07:00
Frank 3b29e173c2 refactor(media): mount the view inside /var/lib/media-meta instead of under /mnt
The merged door was /mnt/media, which read like a third volume and split the host
state across two trees. It is now /var/lib/media-meta/view, so the array layer,
the SSD upper layer, the kernel workdir, the reap trash and the merged tree are
one directory — and the mount table gains no new /mnt entry beside the array.

systemd names a mount unit after its ESCAPED path, so the repo copy keeps the
readable name media-meta-view.mount while `install` writes the host file as
var-lib-media\x2dmeta-view.mount; `plan` prints both and the page records them, so
nobody hunts for a unit that "does not exist". install also creates the mount
point itself, because systemd will not.

The seam now additionally pins that the unit's Where= is exactly the path both
containers bind — guard and bind cannot drift apart. The Quadlet Lifecycle
phantom-path example is corrected too: it named an invented /mnt/media/*, while
the real phantom is the legacy /opt/jellyfin/* that dankserver/compose.yaml still
carries for a service whose roots moved to /home/dank/Containers/jellyfin.
2026-10-30 22:05:31 -07:00
Frank 9706f5d2de docs(media): the view moves sidecars into Plex's reach, it does not enable its NFO agent 2026-10-30 21:42:30 -07:00
Frank 206dc3baf1 docs(media): one page for the metadata view, and the three pages it re-points
New owner page Dankserver/Media Metadata View.md: the shape, the two binds and
why Plex keeps the /mnt/entropy container path, the commands, the laws (SSD-only
upper layer, never mount over an unmounted array, never delete through the view
because a whiteout only hides the array's copy, reap after any relayout, inotify
does not cross the merge), the failure signatures, and the rollback.

Jellyfin.md: /data is the view, so Local metadata works; the
"Read-only file system : '/data/…/movie.nfo'" line that used to be expected noise
is now the signature of a missing view. Plex.md: same bind, one fact changed.
Media Relayout.md: two new laws for the metadata that does not follow a rename.
AGENTS.md: runbook-index row plus a summary row. Root index regenerated.
2026-10-30 21:41:56 -07:00
Frank f5a3badb56 feat(media): the relayout executor names the reap its moves leave behind
A relayout renames on the raw array; the sidecars the media servers wrote live in
the NVMe upper layer of the view and stay at the old coordinates, which is how a
ghost folder (artwork and an .nfo, no video) is born. apply and rollback now
print the follow-up — scripts/media-view.sh reap — at the moment the operator
needs it, and the existing apply seam asserts the hint is in the output.
2026-10-30 21:41:56 -07:00
Frank 3e4b4f24f7 feat(media): share one metadata set between Jellyfin and Plex over an SSD overlay
Both media servers read a Kodi-format sidecar (movie.nfo / tvshow.nfo /
episode.nfo) only when it sits next to the media file, and the library lives on
the RAID6 array, deliberately mounted read-only for every container and slow to
seek for a thousand tiny XML reads. So neither server binds the array any more:
both bind /mnt/media, an OverlayFS merge of the array (lowerdir, never written)
with /var/lib/media-meta on the NVMe (upperdir, where every write lands).

Jellyfin binds the view rw at /data and is the fleet's only metadata writer;
Plex binds it ro at /mnt/entropy, the container path its own database already
recorded, so only the host side of that bind moved and its library paths keep
resolving.

scripts/media-view.sh plans, installs, verifies, reports and reaps the view. It
refuses to mount over an unmounted array, refuses an upper layer on rotational
storage, and refuses a non-root install. deploy-remote.sh raises the view before
it restarts either media server, both units carry
ConditionPathIsMountPoint=/mnt/media so a missing view skips the start instead of
letting a scan clear the library database, and the planner routes the installer
and dankserver/host/** to the jellyfin+plex pair.

tests/media_overlay.bats (11 seams) pins the resolved shape, both refusals, the
two binds and their guard, the deployer's ordering, the planner mapping, and the
reap behaviour against a fixture (ghosts listed, then moved to the SSD trash --
files that exist only above are sidecars, not ghosts, so only directories are
candidates).
2026-10-30 21:41:51 -07:00
Frank 86f5903e74 docs(media): how to file a below-threshold episode by hand
Six single-episode files stayed in the movie library when the threshold refused
them; the hand path is the same executor on a hand-written plan and the same
journal. The trap recorded here is the series year -- never inherit it from the
title whose bonus folder an episode happens to be parked in.
2026-10-30 20:45:24 -07:00
Frank bd4ee78811 docs(media): clear the two OKF violations the last push shipped
The seam ran through `grep -c`, which returned success on a non-zero count and
let a red suite through: description over the 120-char cap, and a timestamp
that check-okf reads against the local date, so a UTC stamp a day ahead of
Seattle is 'in the future'. index.md regenerated for the new description.
docs_completeness 6/6, exit status checked this time.
2026-10-30 20:40:27 -07:00
Frank b7b39cdc92 docs(media): Jellyfin 12.4 needs the MediaBrowser header; the scan key is on the box
X-Emby-Token is refused for every key on 12.4 -- which is why this page
carried the false claim that no credential on the box could trigger a scan and
that the two Seerr keys were dead. The documented snippet now authenticates
with Authorization: MediaBrowser Token=..., reads 204 from /Library/Refresh,
and points at ~/.config/secrets/jellyfin (mirrored as JELLYFIN_API_KEY in the
ignored dankserver/.env). Law: prove a 401 against the ApiKeys table before
blaming the key.
2026-10-30 20:39:51 -07:00
Frank 09532e5216 docs(media): a deletion is staged on the item's own filesystem
Trashing 31 GiB of duplicate Monk to ~/.AGENTTRASH would have been a
cross-device copy of the thing being deleted, into 187 GiB of free space, on a
volume at 100% -- and rename(2) refuses it regardless. The stage belongs beside
the item.
2026-10-30 20:37:27 -07:00
Frank e1786e7cf2 docs(media): the Monk duplicate is verified; compare libraries with the series engine
The destination-hold refusal's evidence, stated exactly: 124 episodes both
sides, same episode list, every byte count identical, 31.5 GiB each way,
head+tail blake2b identical on a 6-episode sample.

Plus the law this came out of: a path-substring filter for a series name also
returns other shows (monk matches Monkey Dust), and whichever file a walk
reaches first standing in for an episode turns two libraries into a fake
duplicate — the same sloppiness aimed the other way deletes a unique file.
ep_of on both sides, and a hash before anything is trashed.
2026-10-30 20:33:41 -07:00
dank 409a8d8b0c Merge pull request 'feat(media): a TV mover that will not file a show the library already has' (#96) from feat/media-tv-mover into main 2026-10-30 20:31:38 -07:00
21 changed files with 1495 additions and 62 deletions

No files matched your search

+2
View File
@@ -808,6 +808,7 @@ per-attempt page and no per-swap ledger anywhere else.
| Dankclaw kernel posture (`iommu=pt` since 2026-10-18) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
| Deployment flow | `serverAppConfigs-docs/Architecture/Deployment Flow.md` |
| Movie-library relayout (Jellyfin format, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Relayout.md` |
| SSD-backed metadata view (Jellyfin writes, Plex reads, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Metadata View.md` |
There is no page for a retired endpoint and no change diary. A payload swap
appends one row to the live endpoint's **Eras** table; when the endpoint itself
@@ -850,5 +851,6 @@ history.
| Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. |
| Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. |
| Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. |
| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. |
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
+6 -28
View File
@@ -94,34 +94,12 @@ services:
timeout: 10s
retries: 3
# ═══════════════════════════════════════════════
# Jellyfin — media server (movies, TV, music)
# ═══════════════════════════════════════════════
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
ports:
- "8096:8096/tcp"
- "8920:8920/tcp"
- "7359:7359/udp"
- "1900:1900/udp"
environment:
- JELLYFIN_PublishedServerUrl=http://192.168.0.235:8096
- DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
volumes:
- /opt/jellyfin/config:/config
- /opt/jellyfin/cache:/cache
- /mnt/entropy:/data:ro
devices:
- /dev/dri:/dev/dri
group_add:
- "992"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8096/health"]
interval: 30s
timeout: 10s
retries: 3
# Jellyfin left this file when it became a Quadlet: the container is owned by
# dankserver/jellyfin/quadlet/jellyfin.container, its data roots are under
# /home/dank/Containers/jellyfin, and its library arrives through the SSD
# overlay at /var/lib/media-meta/view (scripts/media-view.sh). The block that
# was here named /opt/jellyfin/* and a read-only array bind — a second owner for
# the container name `jellyfin`, wrong on every path. One owner per container.
# ═══════════════════════════════════════════════
# Calibre-Web — e-book web frontend
@@ -0,0 +1,30 @@
# /etc/systemd/system/var-lib-media\x2dmeta-view.mount
# (repo copy: dankserver/host/systemd/media-meta-view.mount — systemd names a
# mount unit after its ESCAPED path, so the host file carries the \x2d spelling)
#
# Installed by `scripts/media-view.sh install` — the repo copy is the source of
# truth (fleet law: never edit host state by hand).
#
# An OverlayFS merge that shows the media library as one tree while splitting
# where its bytes live: lowerdir is the RAID6 array (the source of truth, never
# written), upperdir is a directory on the NVMe that receives every write. The
# two media servers bind the result at /var/lib/media-meta/view, so Jellyfin's
# sidecars and artwork are SSD bytes — which is what makes them fast to browse
# and what keeps Plex's NFO agent off the spinners — while the media itself stays
# on the array untouched.
#
# RequiresMountsFor is load-bearing: without the array mounted, lowerdir is an
# empty directory and the view would present an empty library as truth.
[Unit]
Description=SSD-backed overlay view of the media library (array + NVMe metadata layer)
RequiresMountsFor=/mnt/entropy
[Mount]
What=overlay
Where=/var/lib/media-meta/view
Type=overlay
Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work
[Install]
WantedBy=local-fs.target
@@ -4,8 +4,9 @@
# Unit name: jellyfin.service (podman 5.x drops the container- prefix)
#
# Data roots migrated from /opt/jellyfin to /home/dank/Containers/jellyfin at
# cutover. All of /mnt/entropy is mounted read-only at /data: nothing automated
# anywhere writes to the entropy volume. Every
# cutover. The library is seen through /var/lib/media-meta/view, the SSD-backed
# overlay view (scripts/media-view.sh): the array stays read-only and untouched,
# while the sidecars this server writes land on the NVMe. Every
# creation-time environment variable of the compose-era container is kept as
# an Environment= line (zero drift); none of them are secrets, so this unit
# needs no EnvironmentFile.
@@ -41,7 +42,12 @@ PublishPort=7359:7359/udp
PublishPort=1900:1900/udp
Volume=/home/dank/Containers/jellyfin/config:/config
Volume=/home/dank/Containers/jellyfin/cache:/cache
Volume=/mnt/entropy:/data:ro
# The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo /
# episode.nfo + artwork) and those bytes belong on the SSD. The condition is the
# fail-closed guard — starting against a plain, unmounted view directory would let
# a scan conclude the library is empty.
Volume=/var/lib/media-meta/view:/data
UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view
AddDevice=/dev/dri
GroupAdd=keep-groups
HealthCmd=curl -f http://localhost:8096/health
+6 -1
View File
@@ -11,7 +11,12 @@ Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/Los_Angeles
Volume=/var/lib/plexmediaserver:/config
Volume=/mnt/entropy:/mnt/entropy:ro
# The container path stays /mnt/entropy on purpose: every path Plex recorded in
# its own database is written against it, so swapping the HOST source of this bind
# to the overlay view re-points Plex at the sidecars without touching its DB.
# Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata.
Volume=/var/lib/media-meta/view:/mnt/entropy:ro
UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view
PublishPort=32400:32400
[Service]
+16 -1
View File
@@ -155,13 +155,14 @@ DANKCLAW_COMPOSE_SERVICES = set()
# Quadlet unit now — the last compose services were the hermes trio, whose units
# have owned their containers since 2026-09-12 and whose compose blocks were
# dead weight that could only create a duplicate container.
# jellyfin left the Dankserver file on 2026-10-30 for the same reason: its Quadlet
# has owned the container since the cutover, so a compose edit must not restart it.
DANKSERVER_COMPOSE_SERVICES = {
"mariadb",
"redis",
"romm",
"copyparty",
"jellyfin",
"calibre-web",
}
COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"}
@@ -421,6 +422,11 @@ def services_for_path(path: str, target: str) -> set[str]:
"tautulli": {"tautulli"},
}
_add(services, *quadlet_services.get(parts[1], set()))
if len(parts) >= 2 and parts[1] == "host":
# dankserver/host/systemd/media-meta-view.mount is the overlay view the
# media servers bind (scripts/media-view.sh installs it on the host),
# so editing it restarts jellyfin and plex and nothing else.
_add(services, "jellyfin", "plex")
if len(parts) >= 2 and parts[1] == "observability":
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
@@ -466,6 +472,15 @@ def services_for_path(path: str, target: str) -> set[str]:
# that service (target_services then keeps it only on the host being
# planned). Editing the collector restarts the running exporter.
_add(services, "gpu-exporter")
if name == "media-view.sh":
# 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
+36
View File
@@ -1202,7 +1202,42 @@ deploy_romm_stack() {
systemctl --user restart "$service.service"
}
ensure_media_view() {
# Both media servers read their library through the SSD-backed overlay view
# (scripts/media-view.sh, dankserver/host/systemd/media-meta-view.mount): the
# array below stays read-only and every sidecar Jellyfin writes lands on the
# NVMe.
# Raise it BEFORE either container starts — a start against a bare view
# directory would let a scan clear the library database. The units also carry
# ConditionPathIsMountPoint, so a missing view skips the start rather than
# damaging anything. A relayout must be followed by `media-view.sh reap`
# (see the Media Relayout page), which this is not.
run_privileged bash scripts/media-view.sh install
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
@@ -1290,6 +1325,7 @@ deploy_copyparty() {
}
deploy_plex() {
ensure_media_view
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
systemctl --user daemon-reload
if ! systemctl --user is-active -q plex.service; then
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env bash
# Metadata-provider keys for Jellyfin on Dankserver, placed from the service's
# host env file with the plugin artifact pinned by content hash.
#
# Jellyfin 12 ships TMDb, OMDb, StudioImages and MusicBrainz as CORE plugins:
# they have no DLL, only a configuration file under the data root, and that
# file is created by the running container. So the fleet's provider state lives
# in two places the repo does not track —
#
# plugins/configurations/Jellyfin.Plugin.Tmdb.xml <TmdbApiKey /> (empty = keyless)
# plugins/configurations/Jellyfin.Plugin.Tvdb.xml <ApiKey /> (needs the plugin)
#
# and both are owned by the container user. This tool is the only thing that
# writes them: it is declarative, idempotent, and it never prints a key, so it
# is safe to run while someone watches.
#
# Keys never enter git and never enter argv (a value in argv is world-readable
# through /proc/<pid>/cmdline). They live in the per-service host env file that
# is the fleet's convention for container credentials (grafana, caddy, pihole,
# copyparty, romm, seerr), mode 600:
#
# /home/dank/Containers/jellyfin/provider-keys
# TMDB_API_KEY=<key>
# TVDB_API_KEY=<key>
# TVDB_PIN=<pin> # optional: only a subscriber-shaped TVDB key
# # needs one; a licensed key does not
#
# TVDB itself is not core in 12 (the metadata provider was cut in 10.9), so it
# is installed from the official catalog, pinned three ways: sha256 of the
# artifact, the plugin's own guid, and its version. A matching sha proves only
# that the bytes are the bytes fetched — the guid check is what says it is the
# official plugin, the version check is what says it is the one this file was
# reviewed against.
#
# Subcommands
# plan print the resolved shape and every pending change, touch nothing
# install fetch+verify the plugin, then write the keys (backing up first)
# verify prove the plugin and both keys are actually in place
#
# Nothing here restarts Jellyfin: the deployer owns that
# (`scripts/deploy.sh dankserver --service jellyfin`), and the plugin is read at
# start-up, so a key written while the server runs is not live until then.
set -euo pipefail
# Jellyfin's data root on Dankserver (the rootless quadlet's config volume).
JF_ROOT="${JF_ROOT:-/home/dank/Containers/jellyfin/config}"
PROV_KEYS="${PROV_KEYS:-/home/dank/Containers/jellyfin/provider-keys}"
TRASH_DIR="${TRASH_DIR:-$HOME/.AGENTTRASH/$(date +%Y%m%d)-jellyfin-providers}"
PLUGINS="$JF_ROOT/plugins"
CONF="$PLUGINS/configurations"
TMDB_XML="$CONF/Jellyfin.Plugin.Tmdb.xml"
TVDB_XML="$CONF/Jellyfin.Plugin.Tvdb.xml"
# repo.jellyfin.org catalog entry for TheTVDB (owner: jellyfin), version 24.0.0.0,
# targetAbi 12.0.0.0 — published 2026-09-09, verified against Jellyfin 12.4.0.
TVDB_PLUGIN_URL="${TVDB_PLUGIN_URL:-https://repo.jellyfin.org/files/plugin/thetvdb/thetvdb_24.0.0.0.zip}"
TVDB_PLUGIN_SHA256="${TVDB_PLUGIN_SHA256:-6302fba3857bdde8d186dd099fac09ca43247041f531a528a2c788add70a5319}"
TVDB_PLUGIN_GUID="${TVDB_PLUGIN_GUID:-a677c0da-fac5-4cde-941a-7134223f14c8}"
TVDB_PLUGIN_VERSION="${TVDB_PLUGIN_VERSION:-24.0.0.0}"
PLUGIN_DIR="thetvdb_${TVDB_PLUGIN_VERSION}"
TVDB_DLL="Jellyfin.Plugin.Tvdb.dll"
die() {
printf 'jellyfin-providers: %s\n' "$*" >&2
exit 1
}
usage() {
printf 'usage: %s plan|install|verify\n' "$0" >&2
exit 2
}
# check_keys — refuse anything that is not a readable private file. An open
# mode is an error, not a warning: a provider key readable by every user on the
# box is the failure this check exists to catch.
check_keys() {
[ -f "$PROV_KEYS" ] ||
die "$PROV_KEYS not found (create it with TMDB_API_KEY= and TVDB_API_KEY=, then chmod 600)"
local mode
mode="$(stat -c '%a' "$PROV_KEYS")"
case "$mode" in
600 | 400) ;;
*) die "$PROV_KEYS is mode $mode; provider keys must be 600" ;;
esac
}
# keys_value <VAR> — the value of one variable in the env file, or nothing.
keys_value() {
awk -v k="$1" -F= '$1 == k { sub(/^[^=]*=/, ""); print; exit }' "$PROV_KEYS"
}
# keys_len <VAR> — the length, and a refusal naming the variable when absent.
# The value itself is never printed.
keys_len() {
local v
v="$(keys_value "$1")"
[ -n "$v" ] || die "$PROV_KEYS has no $1= line"
printf '%s' "${#v}"
}
# xml_value <file> <element> — the element's text, empty when it is absent or
# self-closing.
xml_value() {
python3 - "$1" "$2" <<'PYEOF'
import re, sys
src = open(sys.argv[1], encoding="utf-8").read()
m = re.search(r"<%s(?:\s*/>|>(.*?)</%s>)" % (sys.argv[2], sys.argv[2]), src, re.S)
print(m.group(1).strip() if (m and m.group(1)) else "")
PYEOF
}
# xml_secret <file> <element> <var> — set <element> to that variable from the
# env file. The python process reads the env file itself, so the value never
# appears in argv.
xml_secret() {
python3 - "$1" "$2" "$3" "$PROV_KEYS" <<'PYEOF'
import os, re, sys, tempfile
from xml.sax.saxutils import escape
xml_path, elem, var, keys_path = sys.argv[1:5]
val = ""
for line in open(keys_path, encoding="utf-8"):
key, sep, rest = line.partition("=")
if sep and key.strip() == var:
val = rest.strip()
break
if not val:
raise SystemExit(f"{keys_path} has no {var}= line")
src = open(xml_path, encoding="utf-8").read()
pat = re.compile(r"([ \t]*)<%s(?:\s*/>|>.*?</%s>)" % (elem, elem), re.S)
text = escape(val)
if pat.search(src):
out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1)
else:
out = src.replace("</Configuration>", f" <{elem}>{text}</{elem}>\n</Configuration>", 1)
if out == src:
raise SystemExit(f"{xml_path}: no place to write <{elem}>")
d, base = os.path.split(xml_path)
fd, tmp = tempfile.mkstemp(prefix=base + ".", dir=d)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(out)
os.chmod(tmp, os.stat(xml_path).st_mode & 0o777)
os.replace(tmp, xml_path)
PYEOF
}
# plugin_meta <zip> — "guid version" out of the artifact's own meta.json.
plugin_meta() {
python3 - "$1" <<'PYEOF'
import json, sys, zipfile
meta = json.loads(zipfile.ZipFile(sys.argv[1]).read("meta.json").decode("utf-8"))
print(meta.get("guid", ""), meta.get("version", ""))
PYEOF
}
check_root() {
# Never fabricate a data root: creating plugins/ beside a typo'd path would
# install a plugin Jellyfin never loads and hide the operator's mistake.
[ -d "$CONF" ] || die "no Jellyfin configuration directory at $CONF (is JF_ROOT right?)"
}
check_keys_report() {
check_keys
# A refusal inside a command substitution used as a printf argument would
# be swallowed, so the guard runs as its own statement.
keys_len TMDB_API_KEY >/dev/null
keys_len TVDB_API_KEY >/dev/null
printf '= keys read from %s (mode %s)\n' "$PROV_KEYS" "$(stat -c '%a' "$PROV_KEYS")"
}
# The plugin's state, as one line in the shared vocabulary.
plugin_state() {
if [ -d "$PLUGINS/$PLUGIN_DIR" ]; then
printf '= %s already installed\n' "$PLUGIN_DIR"
return 0
fi
local other
other="$(find "$PLUGINS" -maxdepth 1 -type d -name 'thetvdb_*' ! -name "$PLUGIN_DIR" -printf '%f\n' 2>/dev/null | sort | tr '\n' ' ')"
if [ -n "$other" ]; then
printf '+ install %s (a different version is present: %s)\n' "$PLUGIN_DIR" "$other"
return 0
fi
printf '+ install %s from %s\n' "$PLUGIN_DIR" "${TVDB_PLUGIN_URL#*://}"
}
tmdb_state() {
if [ ! -f "$TMDB_XML" ]; then
printf '! %s is missing — save the TMDb plugin settings once in the UI, then re-run\n' "$TMDB_XML" >&2
return 1
fi
local now want
now="$(xml_value "$TMDB_XML" TmdbApiKey)"
want="$(keys_value TMDB_API_KEY)"
if [ -n "$now" ] && [ "$now" = "$want" ]; then
printf '= TmdbApiKey already set (%s chars)\n' "${#now}"
elif [ -n "$now" ]; then
printf '+ replace the TmdbApiKey in Jellyfin.Plugin.Tmdb.xml\n'
else
printf '+ set TmdbApiKey in Jellyfin.Plugin.Tmdb.xml\n'
fi
}
tvdb_state() {
local now
if [ -f "$TVDB_XML" ]; then
now="$(xml_value "$TVDB_XML" ApiKey)"
if [ -n "$now" ]; then
printf '= Jellyfin.Plugin.Tvdb.xml already has an ApiKey (kept)\n'
else
printf '+ fill the empty ApiKey in Jellyfin.Plugin.Tvdb.xml\n'
fi
else
printf '+ create Jellyfin.Plugin.Tvdb.xml with ApiKey\n'
fi
}
write_plugin() {
local tmp guid version other
other="$(find "$PLUGINS" -maxdepth 1 -type d -name 'thetvdb_*' ! -name "$PLUGIN_DIR" -printf '%f\n' 2>/dev/null | sort | tr '\n' ' ')"
[ -z "$other" ] ||
die "another TheTVDB version is already installed ($other); move it to the trash first — two copies of one plugin means two loads"
tmp="$(mktemp "${TMPDIR:-/tmp}/thetvdb.XXXXXX.zip")"
# The pins are checked before anything is unpacked, so a bad artifact never
# reaches the plugins directory.
curl -fsSL --max-time 300 -o "$tmp" "$TVDB_PLUGIN_URL" ||
{ rm -f "$tmp"; die "could not fetch $TVDB_PLUGIN_URL"; }
local got
got="$(sha256sum "$tmp" | cut -d' ' -f1)"
if [ "$got" != "$TVDB_PLUGIN_SHA256" ]; then
rm -f "$tmp"
die "artifact sha256 is $got, pinned $TVDB_PLUGIN_SHA256 — refusing to install"
fi
read -r guid version <<<"$(plugin_meta "$tmp")"
if [ "$guid" != "$TVDB_PLUGIN_GUID" ]; then
rm -f "$tmp"
die "artifact guid is $guid, expected the official $TVDB_PLUGIN_GUID — refusing to install"
fi
if [ "$version" != "$TVDB_PLUGIN_VERSION" ]; then
rm -f "$tmp"
die "artifact version is $version, pinned $TVDB_PLUGIN_VERSION — refusing to install"
fi
mkdir -p "$PLUGINS/$PLUGIN_DIR"
python3 -m zipfile -e "$tmp" "$PLUGINS/$PLUGIN_DIR/"
rm -f "$tmp"
[ -f "$PLUGINS/$PLUGIN_DIR/$TVDB_DLL" ] ||
die "$PLUGIN_DIR has no $TVDB_DLL — the artifact does not match this plugin"
printf '+ installed %s\n' "$PLUGIN_DIR"
}
backup_to_trash() {
local f="$1"
mkdir -p "$TRASH_DIR"
local dest="$TRASH_DIR/$(basename "$f").$(date +%H%M%S)"
cp -p "$f" "$dest"
printf '+ previous copy kept at %s\n' "$dest"
}
cmd_write_keys() {
local now want
KEYS_CHANGED=0
# TMDb: the running container owns this file and every setting in it. Only
# the key element is touched, so country, language and adult settings
# survive the edit.
[ -f "$TMDB_XML" ] ||
die "$TMDB_XML is missing — save the TMDb plugin settings once in the UI, then re-run"
now="$(xml_value "$TMDB_XML" TmdbApiKey)"
keys_len TMDB_API_KEY >/dev/null
want="$(keys_value TMDB_API_KEY)"
if [ "$now" = "$want" ]; then
printf '= TmdbApiKey already set (%s chars)\n' "${#now}"
else
backup_to_trash "$TMDB_XML"
xml_secret "$TMDB_XML" TmdbApiKey TMDB_API_KEY
printf '+ set TmdbApiKey (%s chars)\n' "${#want}"
KEYS_CHANGED=1
fi
# TVDB: never overwrite a config the operator saved through the UI.
if [ -f "$TVDB_XML" ]; then
now="$(xml_value "$TVDB_XML" ApiKey)"
if [ -n "$now" ]; then
printf '= Jellyfin.Plugin.Tvdb.xml already has an ApiKey (kept)\n'
else
keys_len TVDB_API_KEY >/dev/null
backup_to_trash "$TVDB_XML"
xml_secret "$TVDB_XML" ApiKey TVDB_API_KEY
if [ -n "$(keys_value TVDB_PIN)" ] && [ -z "$(xml_value "$TVDB_XML" Pin)" ]; then
xml_secret "$TVDB_XML" Pin TVDB_PIN
fi
printf '+ filled the ApiKey in Jellyfin.Plugin.Tvdb.xml\n'
KEYS_CHANGED=1
fi
else
keys_len TVDB_API_KEY >/dev/null
cat >"$TVDB_XML" <<XML
<?xml version="1.0" encoding="utf-8"?>
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<ApiKey />
</Configuration>
XML
xml_secret "$TVDB_XML" ApiKey TVDB_API_KEY
if [ -n "$(keys_value TVDB_PIN)" ]; then
xml_secret "$TVDB_XML" Pin TVDB_PIN
fi
printf '+ created Jellyfin.Plugin.Tvdb.xml with ApiKey\n'
KEYS_CHANGED=1
fi
}
cmd_plan() {
check_root
check_keys_report
printf 'root %s\n' "$JF_ROOT"
plugin_state
tmdb_state
tvdb_state
}
cmd_install() {
check_root
check_keys_report
KEYS_CHANGED=0
if [ ! -d "$PLUGINS/$PLUGIN_DIR" ]; then
write_plugin
KEYS_CHANGED=1
else
printf '= %s already installed\n' "$PLUGIN_DIR"
fi
cmd_write_keys
if [ "$KEYS_CHANGED" -eq 1 ]; then
# Nothing is restarted here on purpose: this host may be playing media
# right now, and both the plugin and its keys are read at start-up.
printf 'next: scripts/deploy.sh dankserver --service jellyfin (the plugin and keys load at start-up)\n'
else
printf '= no change\n'
fi
}
cmd_verify() {
check_root
local bad=0
if [ ! -d "$PLUGINS/$PLUGIN_DIR" ]; then
printf 'MISSING plugin directory %s (thetvdb not installed)\n' "$PLUGIN_DIR" >&2
bad=1
else
local dlls abi
dlls="$(find "$PLUGINS/$PLUGIN_DIR" -maxdepth 1 -name '*.dll' | wc -l)"
[ "$dlls" -ge 1 ] || {
printf 'MISSING %s inside %s\n' "$TVDB_DLL" "$PLUGIN_DIR" >&2
bad=1
}
abi="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("targetAbi","?"))' \
"$PLUGINS/$PLUGIN_DIR/meta.json" 2>/dev/null || echo '?')"
printf 'plugin %s: %s dll(s), targetAbi %s\n' "$PLUGIN_DIR" "$dlls" "$abi"
fi
local pair elem n
for pair in "$TMDB_XML|TmdbApiKey" "$TVDB_XML|ApiKey"; do
elem="${pair##*|}"
if [ ! -f "${pair%%|*}" ]; then
printf 'MISSING %s\n' "${pair%%|*}" >&2
bad=1
continue
fi
if ! python3 -c 'import sys, xml.dom.minidom; xml.dom.minidom.parse(sys.argv[1])' "${pair%%|*}"; then
printf 'UNPARSEABLE %s\n' "${pair%%|*}" >&2
bad=1
continue
fi
n="$(xml_value "${pair%%|*}" "$elem")"
if [ -z "$n" ]; then
printf 'MISSING key: %s has no %s value\n' "$(basename "${pair%%|*}")" "$elem" >&2
bad=1
else
printf '%s %s chars\n' "$elem" "${#n}"
fi
done
[ "$bad" -eq 0 ] || return 1
printf 'providers configured\n'
}
cmd="${1:-}"
case "$cmd" in
plan) cmd_plan ;;
install) cmd_install ;;
verify) cmd_verify ;;
*) usage ;;
esac
+9
View File
@@ -114,6 +114,13 @@ def read_plan(path, root, force):
return mkdirs, keeps, moves
# A relayout moves bytes on the raw array; the sidecars the media servers wrote
# live in the SSD overlay and stay at the old coordinates. Naming the follow-up
# here is the point of use for that fact — see the Media Metadata View page.
METADATA_HINT = ("note: sidecars written through the metadata view did not follow"
" these moves — next: scripts/media-view.sh reap")
def do_apply(args, root, journal, mkdirs, keeps, moves):
"""Preflight everything, then folders, then renames. Never a partial start."""
problems = []
@@ -195,6 +202,7 @@ def do_apply(args, root, journal, mkdirs, keeps, moves):
f" {dirs_new} folders created, {len(keeps)} files stayed put")
print(f"journal: {journal.path} — undo with: media-apply.py rollback"
f" --journal {journal.path} --root {root} --apply")
print(METADATA_HINT)
return 0
@@ -378,6 +386,7 @@ def do_rollback(args, root, journal):
t=datetime.datetime.now(datetime.timezone.utc).isoformat())
print(f"rolled back: {undone} files back where the census found them,"
f" {dirs_gone} empty folders removed, {recreated} swept folders recreated")
print(METADATA_HINT)
return 0
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
# The SSD-backed metadata view that lets Jellyfin and Plex share one metadata set.
#
# Both servers read a Kodi-format sidecar (movie.nfo / tvshow.nfo / episode.nfo)
# ONLY when it sits next to the media file. The media lives on the RAID6 array at
# /mnt/entropy — five spinning disks, deliberately mounted read-only for every
# container, and slow to seek for a thousand tiny XML reads. So neither media
# server binds the array any more: they bind an OverlayFS merge of it with a
# writable layer on the NVMe.
#
# lowerdir /mnt/entropy RAID6 (ROTA=1) — source of truth, never written
# upperdir /var/lib/media-meta/upper NVMe — every sidecar and image byte
# workdir /var/lib/media-meta/work NVMe — kernel bookkeeping
# Where /var/lib/media-meta/view the merged tree jellyfin and plex bind
#
# Everything the view needs — layers, bookkeeping, trash, the merged door — lives
# under the one directory /var/lib/media-meta, so nothing new appears under /mnt.
#
# Consequences worth knowing:
# * A sidecar write is a few KB onto the SSD; the media bytes never move and the
# array is still never written by anything automated.
# * Deleting through the view writes a whiteout into the upper layer instead of
# deleting from the array. Nothing should delete through the view: deletions
# go to .AGENTTRASH on the RAW path, and `reap` clears the resulting ghosts.
# * inotify events on the lower layer do not cross the merge, so a file that
# arrives on the raw array is invisible to a file-system watcher in the view:
# trigger a library refresh (see the Media Relayout page).
#
# Subcommands
# plan [--upper-base D] [--lower D] print the resolved shape, touch nothing
# verify prove the live mount IS that shape
# install [--upper-base D] create the layers, install+enable+start the unit
# reap [--apply] [--upper-base D] [--lower D]
# list upper-layer entries whose media is gone
# status one line per fact a human wants
#
# install must run as root (it writes /etc/systemd/system and mounts); the others
# are read-only and safe unprivileged.
set -euo pipefail
LOWER=/mnt/entropy
UPPER_BASE=/var/lib/media-meta
VIEW=$UPPER_BASE/view
# systemd names a mount unit after its ESCAPED path ('/' -> '-', a literal '-'
# -> '\x2d'), so the unit on the host is var-lib-media\x2dmeta-view.mount. The
# repo copy keeps a readable name; install writes it under the escaped one.
UNIT='var-lib-media\x2dmeta-view.mount'
UNIT_DIR=/etc/systemd/system
# The rootless containers write as this host uid (the deploy user). The upper
# layer has to accept those writes, so it is owned by them.
MEDIA_UID="${MEDIA_VIEW_UID:-1000}"
MEDIA_GID="${MEDIA_VIEW_GID:-1000}"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
UNIT_SRC="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
die() {
printf 'media-view: %s\n' "$*" >&2
exit 1
}
usage() {
printf 'usage: %s plan|verify|install|reap|status [options]\n' "$0" >&2
exit 2
}
cmd="${1:-}"
[ -n "$cmd" ] || usage
shift || true
upper_base="$UPPER_BASE"
lower="$LOWER"
apply=0
while [ $# -gt 0 ]; do
case "$1" in
--upper-base) [ $# -ge 2 ] || usage; upper_base="$2"; shift 2 ;;
--lower) [ $# -ge 2 ] || usage; lower="$2"; shift 2 ;;
--apply) apply=1; shift ;;
*) usage ;;
esac
done
upper="$upper_base/upper"
work="$upper_base/work"
# The device backing a path, and whether the kernel calls it rotational.
rotational_of() {
local src rota
src="$(findmnt -no SOURCE --target "$1" 2>/dev/null || true)"
[ -n "$src" ] || die "cannot find a block device behind $1"
rota="$(lsblk -no ROTA "$src" 2>/dev/null | head -1 || true)"
[ -n "$rota" ] || die "cannot read the rotational flag of $src"
printf '%s %s\n' "$src" "$rota"
}
require_upper_base() {
[ -d "$upper_base" ] \
|| die "refused: upper base $upper_base does not exist (put it on the SSD first)"
}
do_plan() {
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
}
do_verify() {
local fail=0 fst opts src rota
if ! mountpoint -q "$VIEW"; then
printf 'refused: %s is not a mountpoint — a plain directory here is a\n' "$VIEW" >&2
printf ' library that scans itself empty, not a metadata view.\n' >&2
return 1
fi
fst="$(findmnt -no FSTYPE --mountpoint "$VIEW" 2>/dev/null || true)"
if [ "$fst" != overlay ]; then
printf 'refused: %s is mounted %s, not overlay\n' "$VIEW" "${fst:-nothing}" >&2
fail=1
fi
opts="$(findmnt -no OPTIONS --mountpoint "$VIEW" 2>/dev/null || true)"
local want
for want in "lowerdir=$LOWER" "upperdir=$UPPER_BASE/upper" "workdir=$UPPER_BASE/work"; do
case ",$opts," in
*",${want},"*) ;;
*) printf 'refused: %s options lack %s (got: %s)\n' "$VIEW" "$want" "$opts" >&2
fail=1 ;;
esac
done
read -r src rota <<<"$(rotational_of "$UPPER_BASE")"
if [ "$rota" != 0 ]; then
printf 'refused: the upper layer sits on %s which is rotational — metadata\n' "$src" >&2
printf ' belongs on the SSD, not on the array.\n' >&2
fail=1
fi
if [ ! -w "$UPPER_BASE/upper" ]; then
printf 'refused: %s is not writable\n' "$upper" >&2
fail=1
fi
[ "$fail" -eq 0 ] || return 1
printf 'view ok: %s <- overlay(%s,%s)\n' "$VIEW" "$LOWER" "$UPPER_BASE/upper"
}
do_install() {
[ "$(id -u)" -eq 0 ] || die "install must run as root (writes $UNIT_DIR and mounts)"
require_upper_base
mountpoint -q "$LOWER" \
|| die "refused: $LOWER is not mounted; overlaying an empty directory would present an empty library"
local src rota
read -r src rota <<<"$(rotational_of "$upper_base")"
[ "$rota" = 0 ] || die "refused: $upper_base is on $src, a rotational device — the metadata layer must be on the SSD"
[ -f "$UNIT_SRC" ] || die "refused: $UNIT_SRC is missing from $REPO_ROOT"
# systemd will not create a mount point; a missing directory fails the start.
install -d -m 0755 -o "$MEDIA_UID" -g "$MEDIA_GID" "$upper" "$work" "$VIEW"
install -d -m 0700 "$upper_base/.AGENTTRASH"
install -m 0644 "$UNIT_SRC" "$UNIT_DIR/$UNIT"
systemctl daemon-reload
systemctl enable "$UNIT" >/dev/null 2>&1 || true
systemctl start "$UNIT"
do_verify
}
# An upper-layer DIRECTORY with no counterpart below is metadata for a media
# folder that has been moved or renamed on the RAW path — a ghost the media
# servers would still browse. Files are never candidates on their own: a sidecar
# (movie.nfo, an .srt) exists ONLY above, by design, and is exactly what the view
# is for. A copy-up whose lower file was deleted by hand is the one case this
# leaves in place; the raw path is where deletions belong, so it stays visible.
do_reap() {
[ -d "$upper" ] || die "refused: $upper does not exist (no upper layer at $upper_base)"
stamp="$upper_base/.AGENTTRASH/$(date -u +%Y%m%d-%H%M%S)"
count=0
ghosts=0
reap_walk ""
if [ "$count" -eq 0 ] && [ "$ghosts" -eq 0 ]; then
printf 'reap: no ghosts under %s\n' "$upper"
return 0
fi
if [ "$ghosts" -gt 0 ]; then
printf 'reap: %d whiteout%s above — someone deleted THROUGH the view.\n' \
"$ghosts" "$([ "$ghosts" -eq 1 ] || printf s)" >&2
printf ' Leaving them: removing a whiteout un-hides the file it hides.\n' >&2
printf ' Deletions belong in .AGENTTRASH on the raw path.\n' >&2
fi
if [ "$count" -eq 0 ]; then return 0; fi
if [ "$apply" -eq 1 ]; then
printf 'reap: moved %d entr%s to %s\n' "$count" \
"$([ "$count" -eq 1 ] && printf y || printf ies)" "$stamp"
else
printf 'reap: %d ghost entr%s above; re-run with --apply to move them to %s\n' \
"$count" "$([ "$count" -eq 1 ] && printf y || printf ies)" "$stamp"
fi
}
# Descend only where the lower layer still has the directory: a directory that is
# missing below is itself the ghost, and its whole subtree goes with it.
reap_walk() {
local rel="$1" entries abs r name
entries="$(find "$upper${rel:+/$rel}" -mindepth 1 -maxdepth 1 -print 2>/dev/null || true)"
[ -n "$entries" ] || return 0
while IFS= read -r abs; do
r="${abs#"$upper"/}"
name="${abs##*/}"
if [ -c "$abs" ] || printf '%s' "$name" | grep -q '^\.wh\.'; then
ghosts=$((ghosts + 1))
continue
fi
if [ -e "$lower/$r" ]; then
[ -d "$abs" ] && reap_walk "$r"
continue
fi
[ -d "$abs" ] || continue
count=$((count + 1))
printf '%s\n' "$r"
if [ "$apply" -eq 1 ]; then
mkdir -p "$stamp/$(dirname "$r")"
mv "$abs" "$stamp/$r"
fi
done <<<"$entries"
}
do_status() {
if mountpoint -q "$VIEW"; then
printf 'view: %s (%s)\n' "$VIEW" "$(findmnt -no OPTIONS --mountpoint "$VIEW")"
else
printf 'view: %s NOT MOUNTED\n' "$VIEW"
fi
if [ -d "$upper" ]; then
printf 'upper: %s %s files %s\n' "$upper" \
"$(find "$upper" -type f 2>/dev/null | wc -l)" "$(du -sh "$upper" 2>/dev/null | cut -f1)"
else
printf 'upper: %s missing\n' "$upper"
fi
systemctl is-active "$UNIT" 2>/dev/null | sed 's/^/unit: /' || printf 'unit: %s unknown\n' "$UNIT"
}
case "$cmd" in
plan) do_plan ;;
verify) do_verify ;;
install) do_install ;;
reap) do_reap ;;
status) do_status ;;
*) usage ;;
esac
+10
View File
@@ -94,6 +94,16 @@ for path in "${CHANGED[@]}"; do
dankserver/.env.example|dankclaw/.env.example)
add_test tests/env_consistency.bats
;;
# The SSD-backed metadata view: the installer, the host mount unit, and the
# two media units that bind the view rather than the raw array.
scripts/media-view.sh|dankserver/host/*)
add_test tests/media_overlay.bats
add_test tests/dankserver_quadlet.bats
add_test tests/deployment_scoping.bats
;;
dankserver/jellyfin/quadlet/*|dankserver/plex/quadlet/*)
add_test tests/media_overlay.bats
;;
shared/homepage/*)
add_test tests/homepage_caddy.bats
add_test tests/homepage_config.bats
@@ -195,8 +195,9 @@ deploy.sh, never a live env poke.
check every namespace (`sudo podman ps -a`) and native units
(`systemctl list-units`) before declaring a service absent. Compare
compose mount sources against the real filesystem — compose files name
phantom paths (e.g. `/mnt/media/*` vs the real LUKS array at
`/mnt/entropy`). Fix compose through git first, deploy second.
phantom paths (the legacy `dankserver/compose.yaml` still names `/opt/jellyfin/*`
for a service whose roots moved to `/home/dank/Containers/jellyfin`). Fix
compose through git first, deploy second.
2. **Pre-create bind-mount sources** (`sudo mkdir -p` + `chown 1000:1000`)
or creation fails `statfs …: no such file or directory`.
3. **Staged bring-up:** infra first (`up -d mariadb redis`), poll
+73 -6
View File
@@ -1,9 +1,9 @@
---
type: reference
title: Jellyfin
description: "Jellyfin on Dankserver: quadlet unit, entropy-volume data layout, ports, client 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-09-03T00:00:00Z
timestamp: 2026-10-30T00:00:00Z
---
# Jellyfin
@@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
| Image | `ghcr.io/jellyfin/jellyfin:latest` |
| Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp |
| Data roots | `/home/dank/Containers/jellyfin/config:/config`, `/home/dank/Containers/jellyfin/cache:/cache` |
| Media | `Volume=/mnt/entropy:/data:ro` — the whole [[Entropy Volume]] mounted read-only; nothing automated writes to it |
| Media | `Volume=/var/lib/media-meta/view:/data` — the [[Media Metadata View]] overlay, **writable**; sidecars land on the NVMe, the [[Entropy Volume]] array underneath stays read-only and untouched |
| GPU | none — no `AddDevice`; CPU decode only (jellyfin-ffmpeg path) |
| Env quirks | `DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true`, `LD_PRELOAD=libjemalloc.so.2`, `MALLOC_TRIM_THRESHOLD_=131072` kept verbatim from the compose-era container (zero-drift cutover) |
| Health | `curl -f http://localhost:8096/health` every 30 s |
@@ -36,11 +36,23 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
```
/home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR)
/home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME)
/mnt/entropy -> /data (read-only media volume)
/var/lib/media-meta/view -> /data (the SSD-backed overlay view)
```
Library folders point at `/data` paths; because the mount uses the same
absolute path as on the host, library paths survive container recreation.
Library folders point at `/data` paths, which are the view's paths: stable
across container recreation, and identical to the raw array layout below it.
`Local metadata` — save metadata to media folders — is enabled, and it works
because `/data` is the [[Media Metadata View]] rather than the read-only array:
`movie.nfo`, `tvshow.nfo`, `episode.nfo` and artwork are written once, on the
NVMe upper layer, and [[Plex]] reads the same files. Jellyfin is the fleet's
only metadata writer.
**Failure signature:** `System.IO.IOException: Read-only file system :
'/data/Movies/<film>/movie.nfo'` means the container is binding the raw array
instead of the view — the view is unmounted or the quadlet drifted. That used to
be the expected state (pre-view, every item update logged it); today it is a
fault. Run `scripts/media-view.sh status`.
## Ports & access
@@ -105,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).
@@ -0,0 +1,117 @@
---
type: reference
title: Media Metadata View
description: "The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback."
tags: [dankserver, media-server, jellyfin, plex, storage]
timestamp: 2026-10-30T00:00:00Z
---
# Media Metadata View
`/var/lib/media-meta/view` is an OverlayFS merge of the media library: the RAID6
array below, a directory on the NVMe above. It exists so [[Jellyfin]] can write
Kodi-format sidecars (`movie.nfo`, `tvshow.nfo`, `episode.nfo`, artwork) that
[[Plex]] can read. Sidecar metadata is only useful sitting next to the media
file, and the array is read-only and slow — so the media bytes stay there and the
*writes* land on SSD. One tree to both servers, one copy of the media.
The mount point is not a volume and holds nothing while unmounted. It sits inside
`/var/lib/media-meta` so that layers, kernel bookkeeping, the reap trash and the
merged door are one directory — and so the mount table grows no new `/mnt` entry
next to the array.
## Shape
| Item | Value |
|---|---|
| Lower layer | `/mnt/entropy` — the RAID6 array, source of truth, never written |
| Upper layer | `/var/lib/media-meta/upper` — NVMe; every write lands here |
| Work dir | `/var/lib/media-meta/work` — overlayfs internals, same filesystem |
| Mount point | `/var/lib/media-meta/view` — the merged tree both servers bind |
| Unit (repo) | `dankserver/host/systemd/media-meta-view.mount` — the source of truth |
| Unit (host) | `var-lib-media\x2dmeta-view.mount` — systemd names a mount unit after its escaped path (`-` becomes `\x2d`); `media-view.sh install` writes the repo file under that name |
| Installer | `scripts/media-view.sh` — `plan` / `install` / `verify` / `status` / `reap` |
| Written by | Jellyfin only (Plex binds read-only); nothing else in the fleet touches it |
Both consumers guard the view with
`UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view`, and
`scripts/deploy.sh dankserver --service jellyfin|plex` calls `media-view.sh
install` + `verify` before it restarts either container.
| Consumer | Bind | Mode | Why that path |
|---|---|---|---|
| [[Jellyfin]] | `/var/lib/media-meta/view:/data` | rw | It is the metadata writer; its sidecars become SSD bytes |
| [[Plex]] | `/var/lib/media-meta/view:/mnt/entropy` | ro | Every path in the PMS database was recorded as `/mnt/entropy/...`; only the HOST side of the bind moved, so its database still resolves |
Putting the sidecars inside Plex's reach is not the same as making Plex use them:
a library parses `.nfo` only with its *Local media information* agent enabled, and
this change touches no library setting.
## Commands
```bash
scripts/media-view.sh plan # print the resolved shape, touch nothing
sudo scripts/media-view.sh install # create dirs, install the unit, mount, enable
scripts/media-view.sh verify # is the view really the overlay? (deploy gate)
scripts/media-view.sh status # mount options + upper-layer size/file count
scripts/media-view.sh reap # list ghost metadata (media moved, sidecars left)
sudo scripts/media-view.sh reap --apply # move ghosts to the SSD trash, never delete
```
`--upper-base DIR` and `--lower DIR` relocate the layers for `plan` and `reap`;
the defaults are the host values above.
## Laws
- **The upper layer must sit on non-rotational storage.** `install` and `verify`
refuse via `lsblk -dno ROTA` if the upper base lives on the array: an overlay
whose upper is the spinners is the original problem with extra steps, and it
silently turns every metadata write back into a seek.
- **Never mount the view over an unmounted lower.** `RequiresMountsFor=/mnt/entropy`
plus the container `ConditionPathIsMountPoint` guards: an empty `lowerdir`
presents an *empty library* as truth, and a media scan would then clear the
database of everything. systemd does not create a mount point, so `install`
creates `view` itself; without it the mount simply fails.
- **Never delete through the view.** A delete writes an overlayfs whiteout
(`.wh.<name>`) in the upper layer, which hides the array's copy without
removing it — the file reappears the moment the view is unmounted, and the
upper layer now carries a landmine. Deletions belong on the raw path
(`/mnt/entropy`), which the relayout tool already treats as the only writer.
`reap` refuses to remove whiteouts and only warns about them.
- **A relayout must be followed by `reap`.** Renames happen on the raw path, so
sidecars the view already wrote stay at the old coordinates: a ghost folder
the servers browse and find nothing in. `reap` lists upper-layer directories
with no counterpart below and moves them (with their subtree) into a dated
`.AGENTTRASH/` under the SSD base. It is recursive and file-safe: a sidecar
that exists only above is normal, so only *directories* are candidates.
- **File-watcher events do not cross the overlay.** Plex's NFO agent and
Jellyfin's real-time monitor see inotify events for writes that land *in the
upper layer*, not for changes on the array underneath. After any relayout or
externally-written metadata, trigger a library refresh — see
[[Media Relayout]] for the refresh procedure.
## Failure signatures
| Symptom | Meaning |
|---|---|
| `jellyfin.service` / `plex.service` show `ConditionPathIsMountPoint=... was not met` and stay inactive | The view is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. |
| Jellyfin log: `Read-only file system : '/data/Movies/…/movie.nfo'` | The container is binding the raw array, not the view — the quadlet drifted back or the view is gone. `media-view.sh status`. |
| A folder appears in a media server with artwork/`.nfo` but no video | Ghost metadata from a relayout. `media-view.sh reap`. |
| `verify` prints `refused: …/view is not a mountpoint` | A plain directory is sitting where the overlay belongs. That is the fail-closed guard working; `install` on the box. |
| `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. |
## Rollback
Revert the commit that pointed the two quadlets at the view and re-deploy both
services — they bind `/mnt/entropy` read-only again, which is the pre-view shape.
The NVMe upper layer can stay: nothing reads it once the binds are back, and it
holds no state either server needs. Unmounting alone
(`systemctl stop 'var-lib-media\x2dmeta-view.mount'`) is safe while no container
binds it, and the array is never modified by any of this.
## Links
- [[Jellyfin]] — the metadata writer; its data layout section.
- [[Plex]] — the read-only consumer; in-place bind-mount history.
- [[Media Relayout]] — the tool that moves media on the raw path and the reason `reap` exists.
- [[Entropy Volume]] — the array underneath.
@@ -1,7 +1,7 @@
---
type: reference
title: Media Relayout
description: "Census, plan and apply the movie library into Jellyfin format: the gates to read, and the rollback."
description: "Census, plan and apply the movie library into Jellyfin format, and move TV out of it: the gates, refusals, rollback."
tags: [dankserver, media-server, jellyfin, plex, procedure]
timestamp: 2026-10-30T00:00:00Z
---
@@ -91,19 +91,23 @@ podman exec -i plex sh -c 'read -r T
done' </tmp/px.tok
```
[[Jellyfin]] has no credential on this box that can trigger a scan: the two keys
in its database are Seerr's, unused since 2026-09-01, and answer 401 to every
call. Mint one (*Dashboard → Advanced → API Keys*, Administrator) and store it in
`~/.config/secrets/jellyfin`, or press *Library → Scan All Libraries* in the web
UI. A scripted scan passes the key in a mode-600 curl config, never in a header
argument:
[[Jellyfin]]'s scan key lives in `~/.config/secrets/jellyfin` (mode 600; the
same value is `JELLYFIN_API_KEY` in the ignored `dankserver/.env`). It rides a
mode-600 curl config, never a header argument, and it must use the
`MediaBrowser` form — Jellyfin 12.4 refuses a bare `X-Emby-Token` for any key:
```bash
umask 077; printf 'header = "X-Emby-Token: %s"\n' "$(cat ~/.config/secrets/jellyfin)" >/tmp/jf.hdr
curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \
-X POST http://127.0.0.1:8096/Library/Refresh
umask 077
{ printf 'header = "Authorization: MediaBrowser Token=\\"%s\\""\n' \
"$(cat ~/.config/secrets/jellyfin)"
echo 'url = "http://127.0.0.1:8096/Library/Refresh"'
echo 'request = "POST"'; } >/tmp/jf.hdr
curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n"
shred -u /tmp/jf.hdr
```
`204` is acceptance; the scan then runs on the server's own schedule.
## TV in the movie library
A box set that arrives in the movie share stays there until someone moves it:
@@ -133,13 +137,22 @@ from the box-set folder. What it refuses it prints and leaves alone:
- a destination two files both claim: the executor refuses the whole plan
before its first rename, and only Frank decides which copy is the keeper.
- an episode the destination library already holds at the same byte count. The
movie share carried a second copy of all 124 Monk episodes — the TV library
has had the same box set for years, every episode the same size, ~31 GiB of
it. The mover leaves such a series in place and says so; a duplicate is
deleted or kept by Frank, never copied into one library by a tool.
movie share carried a second copy of the whole Monk box set — 124 episodes
both sides, the same episode list, every byte count identical, 31.5 GiB each
way, and head+tail blake2b identical on a 6-episode sample. The mover leaves
such a series in place and says so; a duplicate is deleted or kept by Frank,
never copied into one library by a tool.
A sidecar that shares an episode's own name (`.srt`, `.nfo`) moves with it.
Below the threshold the mover prints and leaves, so a lone downloaded episode of
a real show stays in the movie library on purpose — six did on 2026-10-30.
Filing one by hand is the same executor on a hand-written plan and the same
journal; the trap is the series year. Read it from the file's own name or the
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.
## Laws
- **A moved file is a 404 in [[Plex]] until its section is rescanned.** The
@@ -166,6 +179,32 @@ A sidecar that shares an episode's own name (`.srt`, `.nfo`) moves with it.
folder holding it, and extras drifted into a neighbour. A bare number counts
only when it could be a release year, so `1080` and `2160` never pose as
agreement between two names.
- **Compare two media libraries with the series engine, never a path
substring.** Filtering paths for `monk` also returns *Monkey Dust*, and
letting whichever file a walk happens to reach first stand for an episode
paired two libraries into a fake duplicate — the same sloppiness pointed the
other way deletes a file that is unique. The comparison is `media-titles.ep_of`
on both sides: series, season, episode, bytes, and a hash before anything is
trashed.
- **A deletion is staged on the item's own filesystem.** The trash rule names
`~/.AGENTTRASH`, but `/home` and `/mnt/entropy` are different devices: a 31 GiB
stage there is a copy rather than a rename, it has to fit in the 187 GiB of
free space, and `rename(2)` refuses it anyway. Trash goes beside the item —
`/mnt/entropy/.AGENTTRASH/<date>-<topic>/` — so it costs one inode change and
rolls back exactly like the move that made it.
- **A Jellyfin 401 is the header, not the key.** 12.4 authenticates only
`Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token` for
every key, valid or not. This page carried "no credential on this box can
trigger a scan, the two Seerr keys answer 401" for weeks because of that
header — the keys were innocent. Before believing a 401, prove the key against
the server's own table: `select count(*) from ApiKeys where AccessToken = ?`
on `jellyfin.db` opened `mode=ro&immutable=1`.
- **A scan that must see a new folder has to be recursive.** `POST
/Library/Refresh` is the incremental pass, and inotify does not cross a
rootless podman bind, so folders created on the host stay invisible to it. The
move is `POST /Items/{id}/Refresh?Recursive=true` (204) per library, the id
from `GET /Library/VirtualFolders` — its field is `ItemId`, not `Id` — and
that same record's `RefreshStatus` is the progress read.
- **A file whose name carries no words is bonus material, not a folder.**
`.Extra 1 ( 1H 27min multisub ).mkv` parses to `1`; a movie folder named `1`
is not a movie. It becomes a review line telling a human to name the file.
@@ -211,6 +250,18 @@ A sidecar that shares an episode's own name (`.srt`, `.nfo`) moves with it.
path outside `--root`, and a journal shared with the movie relayout would
undo both runs when only one of them is wanted.
- **Sidecar metadata does not follow a rename, because it lives on another
filesystem.** The media servers write `.nfo` and artwork through the
[[Media Metadata View]] (NVMe upper layer); this tool moves bytes on the raw
array path. After any sweep or TV move, run `scripts/media-view.sh reap`, then
the same command with `--apply`: the sidecars left at the old coordinates become
ghost folders — artwork and an `.nfo` with no video — that both servers browse
and report as content.
- **Nothing deletes through the metadata view.** A delete there writes an overlayfs
whiteout in the upper layer, which hides the array's copy without removing it;
the file returns when the view is unmounted. Deletions in this pipeline happen
on the raw path only, which is what the journal can undo.
## Rollback
One command, from the journal of the run:
@@ -221,7 +272,7 @@ file cannot be accounted for. A rolled-back journal refuses to roll back twice.
## See Also
[[Jellyfin]], [[Plex]], [[Entropy Volume]], [[Testing Policy]], [[Storage Policy]]
[[Jellyfin]], [[Plex]], [[Media Metadata View]], [[Entropy Volume]], [[Testing Policy]], [[Storage Policy]]
## Citations
+6 -4
View File
@@ -4,7 +4,7 @@ description: "Plex Media Server is a **containerized media server on Dankserver*
tags:
- dankserver
- plex
timestamp: 2026-10-21
timestamp: 2026-10-30
---
# Plex
@@ -13,7 +13,8 @@ Plex Media Server is a **containerized media server on Dankserver**: a podman
quadlet (`plex.service`) under the systemd **user** generator, generated from
`dankserver/plex/quadlet/plex.container`. It shares the host with a second live
media server, [[Jellyfin]] (`jellyfin.service`, listening on 8096); both serve
from the same read-only Entropy Volume mount.
from the same tree — the [[Media Metadata View]] overlay — so the Kodi-format
sidecars Jellyfin writes are the files Plex reads, with no copy and no sync.
**Host:** Dankserver — **Ports:** 32400/tcp (PMS web + API), 32400 is the
only port PMS needs for client streaming; LAN clients connect directly,
@@ -26,7 +27,7 @@ public traffic can be fronted by Caddy like every other vhost.
| Runtime | podman quadlet, `plex.service` (user manager) — migrated from the native `plexmediaserver.service` OS-package unit (2026-09) |
| Image | `lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329` (pinned; verified == the registry's `latest` manifest digest at pin time: `sha256:8f655bad…e9e2334`) |
| Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes |
| Media | `/mnt/entropy` mounted at the same absolute path **read-only** — every library path in the PMS database stayed valid, zero re-scan |
| Media | `/var/lib/media-meta/view` (the [[Media Metadata View]] overlay) bound at the same absolute path `/mnt/entropy` **read-only** — every library path in the PMS database stayed valid, zero re-scan |
| Identity | fleet rootless model — container root maps to the `dank` user (1000:1000); the deployer chowns the data root on first cutover |
| Port | `32400/tcp` published on `0.0.0.0` — zero client reconfiguration |
| Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` |
@@ -38,7 +39,8 @@ public traffic can be fronted by Caddy like every other vhost.
The migration moved zero bytes: the container bind-mounts the native data
root where it already lived, and the media archive re-mounts at
`/mnt/entropy` inside the container, so every absolute library path in the
`/mnt/entropy` inside the container — only the HOST side of that bind has moved
(array, then the overlay view), so every absolute library path in the
PMS database keeps resolving. The one metadata change: rootless podman
cannot map the real host uid 997, so the fleet model applies — container
root runs as the `dank` user, and the deployer adopts the data root with
+3 -2
View File
@@ -49,9 +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, entropy-volume data layout, ports, client 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 Relayout]] | Census, plan and apply the movie library into Jellyfin format: the gates to read, and the rollback. |
| [[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. |
| [[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. |
+10
View File
@@ -133,6 +133,16 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
[ -z "$output" ]
}
@test "a compose edit never restarts a Quadlet-owned media server" {
# dankserver/compose.yaml is an inventory; the media servers' Quadlets own
# their containers. A compose edit that restarted them would cut real viewers
# for a change that cannot affect them.
run python3 "$PLANNER" --target dankserver --paths dankserver/compose.yaml
[ "$status" -eq 0 ]
! printf '%s\n' "$output" | grep -qx jellyfin
! printf '%s\n' "$output" | grep -qx plex
}
@test "compose planner narrows a compose edit to the changed service" {
local base head
base=$(git -C "$REPO_ROOT" rev-parse HEAD)
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env bats
# Seam: scripts/jellyfin-providers.sh — the only mechanism that puts a
# metadata-provider key into Jellyfin's plugin configuration on Dankserver.
#
# Output vocabulary the tests depend on:
# "+ <action>" something this run will change
# "= <fact>" something already true, so nothing changes
#
# The contracts, in the order the seam tests them:
# 1. plan reports every action and never prints a key;
# 2. a secret file that is not mode 600 is refused by name and nothing is
# written — a key readable by other users is an error, not a warning;
# 3. a downloaded plugin artifact is accepted only on its pinned sha256, its
# official guid and its pinned version (a swapped or downgraded artifact
# is refused before anything lands in plugins/);
# 4. the TMDb key is written into the EXISTING config with every other
# setting preserved — Jellyfin owns that file, and clobbering it would
# silently reset country, language and adult settings;
# 5. a TVDB config the operator already saved through the UI is kept;
# 6. install is idempotent: a second run changes no byte;
# 7. whatever is replaced is copied to the trash first (fleet law: never
# overwrite without a recoverable copy);
# 8. verify answers whether the endpoint is configured, key LENGTH only;
# 9. no plugin configuration XML is tracked in git, the deployer places the
# keys BEFORE it restarts Jellyfin, and the planner routes a change to this
# installer to jellyfin.
load helpers
PROV="$REPO_ROOT/scripts/jellyfin-providers.sh"
TMDB_KEY="tmdbk3a9Zq7Rtestkey"
TVDB_KEY="tvdbk8b2Wt4Ptestkey"
GUID_OFFICIAL="a677c0da-fac5-4cde-941a-7134223f14c8"
# make_plugin <file> <guid> <version> — a zip shaped like the official TheTVDB
# artifact (meta.json + its two DLLs), so extraction is exercised for real.
make_plugin() {
python3 - "$1" "$2" "$3" <<'PYEOF'
import json, sys, zipfile
path, guid, version = sys.argv[1], sys.argv[2], sys.argv[3]
meta = {
"category": "MoviesAndShows",
"guid": guid,
"name": "TheTVDB",
"owner": "jellyfin",
"targetAbi": "12.0.0.0",
"version": version,
}
with zipfile.ZipFile(path, "w") as z:
z.writestr("meta.json", json.dumps(meta))
z.writestr("Jellyfin.Plugin.Tvdb.dll", "dll-bytes")
z.writestr("Tvdb.Sdk.dll", "dll-bytes")
PYEOF
sha256sum "$1" | cut -d' ' -f1
}
setup() {
JF="$BATS_TEST_TMPDIR/jf"
TRASH="$BATS_TEST_TMPDIR/trash"
SEC="$BATS_TEST_TMPDIR/secrets"
mkdir -p "$JF/plugins/configurations" "$TRASH" "$SEC"
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<MetadataCountryCode>US</MetadataCountryCode>
<PreferOriginalLanguage />
<Language>en</Language>
<EnableCinematicForTrailers>false</EnableCinematicForTrailers>
<IncludeAdult>false</IncludeAdult>
<MaxCastMembers>25</MaxCastMembers>
<AddGenresFromKeywords>true</AddGenresFromKeywords>
<TmdbApiKey />
</Configuration>
XML
KEYS="$SEC/provider-keys"
printf 'TMDB_API_KEY=%s\nTVDB_API_KEY=%s\n' "$TMDB_KEY" "$TVDB_KEY" >"$KEYS"
chmod 600 "$KEYS"
ZIP="$BATS_TEST_TMPDIR/thetvdb.zip"
PIN_SHA="$(make_plugin "$ZIP" "$GUID_OFFICIAL" 24.0.0.0)"
PROV_ENV=(
JF_ROOT="$JF"
PROV_KEYS="$KEYS"
TRASH_DIR="$TRASH"
TVDB_PLUGIN_URL="file://$ZIP"
TVDB_PLUGIN_SHA256="$PIN_SHA"
TVDB_PLUGIN_GUID="$GUID_OFFICIAL"
TVDB_PLUGIN_VERSION=24.0.0.0
)
}
prov() { env "${PROV_ENV[@]}" bash "$PROV" "$@"; }
tree_fingerprint() { find "$JF" -type f | sort | xargs -r sha256sum | sha256sum; }
@test "plan names the plugin and both config files, and prints no key" {
run prov plan
[ "$status" -eq 0 ]
printf '%s\n' "${lines[@]}" | grep -q '^+ install thetvdb_24\.0\.0\.0'
printf '%s\n' "${lines[@]}" | grep -q '^+ set TmdbApiKey'
printf '%s\n' "${lines[@]}" | grep -q '^+ create Jellyfin\.Plugin\.Tvdb\.xml'
# The whole point of running this over a shoulder: a key never appears.
! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY"
! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY"
}
@test "plan writes nothing" {
before="$(tree_fingerprint)"
run prov plan
[ "$status" -eq 0 ]
[ "$(tree_fingerprint)" = "$before" ]
}
@test "an already-configured endpoint reports no pending action" {
prov install >/dev/null
run prov plan
[ "$status" -eq 0 ]
! printf '%s\n' "${lines[@]}" | grep -q '^+ '
printf '%s\n' "${lines[@]}" | grep -q '= thetvdb_24\.0\.0\.0 already installed'
printf '%s\n' "${lines[@]}" | grep -q '= TmdbApiKey already set'
printf '%s\n' "${lines[@]}" | grep -q '= Jellyfin\.Plugin\.Tvdb\.xml already has an ApiKey'
}
@test "a keys file that is not mode 600 is refused and nothing is written" {
chmod 644 "$KEYS"
before="$(tree_fingerprint)"
run prov install
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qF '600'
[ "$(tree_fingerprint)" = "$before" ]
}
@test "a missing TVDB key is refused by name before anything is fetched" {
printf 'TMDB_API_KEY=%s\n' "$TMDB_KEY" >"$KEYS"
chmod 600 "$KEYS"
run prov install
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qF 'TVDB_API_KEY'
[ ! -d "$JF/plugins/thetvdb_24.0.0.0" ]
}
@test "a root with no plugin directory is refused instead of fabricated" {
# Creating plugins/ inside a wrong path would install a plugin Jellyfin
# never loads, and would hide the operator's typo.
run env "${PROV_ENV[@]}" JF_ROOT="$BATS_TEST_TMPDIR/nope" bash "$PROV" install
[ "$status" -ne 0 ]
[ ! -d "$BATS_TEST_TMPDIR/nope/plugins" ]
}
@test "an artifact whose sha256 is not the pinned one is refused" {
run env "${PROV_ENV[@]}" TVDB_PLUGIN_SHA256="$(printf 'f%.0s' {1..64})" \
bash "$PROV" install
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'sha256'
[ ! -d "$JF/plugins/thetvdb_24.0.0.0" ]
}
@test "an artifact that is not the official TVDB plugin is refused" {
# A matching sha proves only that the bytes are the bytes we fetched.
EVIL="$BATS_TEST_TMPDIR/evil.zip"
EVIL_SHA="$(make_plugin "$EVIL" "deadbeef-0000-0000-0000-000000000000" 24.0.0.0)"
run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$EVIL" TVDB_PLUGIN_SHA256="$EVIL_SHA" \
bash "$PROV" install
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'guid'
[ ! -d "$JF/plugins/thetvdb_24.0.0.0" ]
}
@test "an artifact that is not the pinned version is refused" {
OLD="$BATS_TEST_TMPDIR/old.zip"
OLD_SHA="$(make_plugin "$OLD" "$GUID_OFFICIAL" 22.0.0.0)"
run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$OLD" TVDB_PLUGIN_SHA256="$OLD_SHA" \
bash "$PROV" install
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'version'
[ ! -d "$JF/plugins/thetvdb_24.0.0.0" ]
}
@test "a different plugin version already in plugins/ is refused, not doubled" {
# Two thetvdb_* directories means Jellyfin loads one plugin twice; the
# operator removes the old one (into the trash) deliberately, not by an
# installer quietly adding a second.
prov install >/dev/null
OLD="$BATS_TEST_TMPDIR/old.zip"
OLD_SHA="$(make_plugin "$OLD" "$GUID_OFFICIAL" 22.0.0.0)"
run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$OLD" TVDB_PLUGIN_SHA256="$OLD_SHA" \
TVDB_PLUGIN_VERSION=22.0.0.0 bash "$PROV" install
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qF 'thetvdb_24.0.0.0'
[ ! -d "$JF/plugins/thetvdb_22.0.0.0" ]
}
@test "install places the plugin, keys TMDb, and preserves every other setting" {
run prov install
[ "$status" -eq 0 ]
! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY"
! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY"
[ -f "$JF/plugins/thetvdb_24.0.0.0/meta.json" ]
[ -f "$JF/plugins/thetvdb_24.0.0.0/Jellyfin.Plugin.Tvdb.dll" ]
local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml"
# Jellyfin owns this file: the settings it wrote must survive the edit.
for setting in MetadataCountryCode Language IncludeAdult MaxCastMembers AddGenresFromKeywords; do
grep -qF "<$setting>" "$cfg" || {
echo "lost $setting"
return 1
}
done
grep -qF '<IncludeAdult>false</IncludeAdult>' "$cfg"
grep -qF "<TmdbApiKey>$TMDB_KEY</TmdbApiKey>" "$cfg"
grep -qF "<ApiKey>$TVDB_KEY</ApiKey>" "$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
}
@test "a TVDB config the operator saved through the UI is kept" {
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<ApiKey>ui-saved-key</ApiKey>
<EnableSeasonPathMatching>true</EnableSeasonPathMatching>
</Configuration>
XML
run prov install
[ "$status" -eq 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'kept'
local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
grep -qF '<ApiKey>ui-saved-key</ApiKey>' "$cfg"
grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg"
}
@test "a pin in the TVDB secret lands in the plugin config" {
# A subscriber-shaped key needs the account's PIN beside it; a licensed key
# does not, so the element appears only when the secret carries one.
printf 'TMDB_API_KEY=%s\nTVDB_API_KEY=%s\nTVDB_PIN=123456\n' "$TMDB_KEY" "$TVDB_KEY" >"$KEYS"
chmod 600 "$KEYS"
prov install >/dev/null
grep -qF '<Pin>123456</Pin>' "$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
}
@test "install is idempotent: a second run changes no byte" {
prov install >/dev/null
before="$(tree_fingerprint)"
run prov install
[ "$status" -eq 0 ]
[ "$(tree_fingerprint)" = "$before" ]
printf '%s\n' "${lines[@]}" | grep -qiF 'no change'
# One plugin directory only: two versions in plugins/ means two loads.
[ "$(find "$JF/plugins" -maxdepth 1 -type d -name 'thetvdb_*' | wc -l)" -eq 1 ]
}
@test "what install replaces is copied to the trash first" {
prov install >/dev/null
# The TMDb config existed before this tool touched it, so the pre-edit copy
# must stay recoverable.
[ -n "$(find "$TRASH" -name 'Jellyfin.Plugin.Tmdb.xml*' -print -quit)" ]
grep -qF '<TmdbApiKey />' "$(find "$TRASH" -name 'Jellyfin.Plugin.Tmdb.xml*' | head -1)"
}
@test "verify refuses an unconfigured endpoint and names what is missing" {
run prov verify
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'thetvdb'
printf '%s\n' "${lines[@]}" | grep -qiF 'TmdbApiKey'
}
@test "verify passes once the plugin and both keys are in place" {
prov install >/dev/null
run prov verify
[ "$status" -eq 0 ]
! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY"
! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY"
# Length, not bytes: the operator sees the key loaded without reading it.
printf '%s\n' "${lines[@]}" | grep -qE 'TmdbApiKey .*[0-9]+ chars'
}
@test "no plugin configuration XML is tracked in this repo" {
# These files hold provider keys and are owned by the running container;
# tracking them would put credentials in git and guarantee drift.
run git -C "$REPO_ROOT" ls-files
[ "$status" -eq 0 ]
! printf '%s\n' "${lines[@]}" | grep -qF 'plugins/configurations'
! printf '%s\n' "${lines[@]}" | grep -qiF 'Plugin.Tvdb.xml'
}
@test "the planner routes this installer to jellyfin" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver \
--paths scripts/jellyfin-providers.sh
[ "$status" -eq 0 ]
[ "$output" = "jellyfin" ]
}
@test "the deployer places the keys before it restarts jellyfin" {
# The plugin and both keys are read at start-up, so a restart that runs
# ahead of the placement boots the old, keyless state.
body="$(sed -n '/^deploy_jellyfin()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")"
[ -n "$body" ]
up="$(printf '%s\n' "$body" | grep -n 'ensure_provider_keys' | head -1 | cut -d: -f1)"
restart="$(printf '%s\n' "$body" | grep -n 'systemctl --user restart jellyfin.service' | head -1 | cut -d: -f1)"
[ "${up:-999}" -lt "${restart:-0}" ]
}
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bats
# Seam: the SSD-backed metadata view (OverlayFS) that lets Jellyfin write
# Kodi-format sidecars for Plex to read, without ever writing to the spindle
# array and without letting either media server start against a missing view.
#
# The contracts, in the order the seam tests them:
# 1. the plan puts the writable layer on the SSD and the media layer below it;
# 2. bad input is refused by name instead of mounting something surprising;
# 3. verify refuses a view that is not mounted (so nothing trusts a directory);
# 4. both media units bind the VIEW and refuse to start without it;
# 5. only those two units touch the view — the LLM and library tenants keep the
# raw volume, so the blast radius is two containers;
# 6. the deployer brings the view up BEFORE it restarts either server;
# 7. the planner routes a change to these files to both media services.
load helpers
VIEW="$REPO_ROOT/scripts/media-view.sh"
JELLY="$REPO_ROOT/dankserver/jellyfin/quadlet/jellyfin.container"
PLEX="$REPO_ROOT/dankserver/plex/quadlet/plex.container"
MOUNT=/var/lib/media-meta/view
UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
@test "the plan names an overlay with the spindle below and the SSD above" {
run bash "$VIEW" plan
[ "$status" -eq 0 ]
# Lower = the array; upper/work = the NVMe. A user asked for metadata to be
# SSD-resident, so a spindle upper layer would defeat the whole mechanism.
[ "${lines[0]}" = "lowerdir=/mnt/entropy" ]
printf '%s\n' "${lines[@]}" | grep -qx 'upperdir=/var/lib/media-meta/upper'
printf '%s\n' "${lines[@]}" | grep -qx 'workdir=/var/lib/media-meta/work'
printf '%s\n' "${lines[@]}" | grep -Fqx "mountpoint=$MOUNT"
# systemd names a mount unit after its escaped path, backslash and all.
printf '%s\n' "${lines[@]}" | grep -Fqx \
'unit=/etc/systemd/system/var-lib-media\x2dmeta-view.mount'
}
@test "install refuses a caller who cannot write the unit directory or mount" {
# Half an install is worse than none: the unit would exist without the
# directories behind it. Refuse by name before anything is created.
if [ "$(id -u)" -eq 0 ]; then
skip "this seam is running as root"
fi
run bash "$VIEW" install
[ "$status" -ne 0 ]
[[ "$output" == *"root"* ]]
}
@test "verify refuses a view that is not mounted" {
# Every consumer trusts the mount point to BE the library. On a host where it
# is a plain empty directory, verify must fail loudly: that is the difference
# between a scan that finds nothing and a scan that empties a library DB.
if mountpoint -q "$MOUNT"; then
skip "$MOUNT is a real mountpoint on this host"
fi
run bash "$VIEW" verify
[ "$status" -ne 0 ]
[[ "$output" == *"$MOUNT"* ]]
}
@test "both media servers bind the view and refuse to start without it" {
for unit in "$JELLY" "$PLEX"; do
grep -q "^Volume=$MOUNT:" "$unit" || return 1
grep -q "^UnitConfigItem=ConditionPathIsMountPoint=$MOUNT$" "$unit" || return 1
# The raw array must no longer be a container source: that bind is what
# made sidecar sharing impossible (read-only) and metadata slow (seeks).
! grep -q '^Volume=/mnt/entropy' "$unit"
done
# Jellyfin is the writer, so it needs the view writable; Plex only reads the
# sidecars, and keeping its bind read-only costs nothing.
grep -q "^Volume=$MOUNT:/data$" "$JELLY"
grep -q "^Volume=$MOUNT:/mnt/entropy:ro$" "$PLEX"
}
@test "only the two media units reference the view" {
run grep -rl --include='*.container' "$MOUNT" "$REPO_ROOT"/dankserver \
"$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro 2>/dev/null
[ "$status" -eq 0 ]
[ "$(printf '%s\n' "$output" | wc -l)" -eq 2 ]
printf '%s\n' "$output" | grep -q 'jellyfin/quadlet/jellyfin.container'
printf '%s\n' "$output" | grep -q 'plex/quadlet/plex.container'
}
@test "the mount unit is an overlay whose upper layer lives on the SSD" {
[ -f "$UNIT" ]
run grep -E '^Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work' "$UNIT"
[ "$status" -eq 0 ]
# The unit must mount exactly where the two containers bind, or the guard and
# the bind disagree and a media server browses an empty directory.
run grep -Fqx "Where=$MOUNT" "$UNIT"
[ "$status" -eq 0 ]
# It must wait for the array to be unlocked: mounting an overlay whose lower
# dir is an empty /mnt/entropy would present an empty library, not an error.
run grep -E '^RequiresMountsFor=/mnt/entropy$' "$UNIT"
[ "$status" -eq 0 ]
}
@test "the deployer raises the view before it restarts either media server" {
for svc in jellyfin plex; do
body="$(sed -n "/^deploy_${svc}()/,/^\}/p" "$REPO_ROOT/scripts/deploy-remote.sh")"
[ -n "$body" ]
up="$(printf '%s\n' "$body" | grep -n 'ensure_media_view' | head -1 | cut -d: -f1)"
up_line="${up:-999}"
restart="$(printf '%s\n' "$body" | grep -n "systemctl --user restart $svc.service" | head -1 | cut -d: -f1)"
restart_line="${restart:-0}"
[ "$up_line" -lt "$restart_line" ]
done
}
@test "the planner routes the view files to both media services" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount
[ "$status" -eq 0 ]
[ "$output" = "jellyfin
plex" ]
}
@test "a docs-only change still plans nothing for the view" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
"serverAppConfigs-docs/Dankserver/Media Metadata View.md"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "reap names the metadata whose media has moved and touches nothing" {
# A relayout moves media on the RAW path; the sidecars the view already wrote
# stay behind at the old coordinates. Left alone they become folders a media
# server browses and finds nothing in.
tmp="$(mktemp -d)"
mkdir -p "$tmp/meta/upper/Movies/Ghost (2020)" "$tmp/meta/upper/Movies/Keeper (2020)" \
"$tmp/meta/upper/TVShows/Big Little Lies/Season 01" "$tmp/meta/work" \
"$tmp/array/Movies/Keeper (2020)" "$tmp/array/TVShows"
: > "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo"
: > "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo"
: > "$tmp/meta/upper/TVShows/Big Little Lies/Season 01/show.nfo"
: > "$tmp/array/Movies/Keeper (2020)/Keeper (2020).mkv"
run bash "$VIEW" reap --upper-base "$tmp/meta" --lower "$tmp/array"
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qxF 'Movies/Ghost (2020)'
# The series folder is still below, so the ghost is the season inside it — and
# it is reported at the level that is actually missing, not deeper.
printf '%s\n' "$output" | grep -qxF 'TVShows/Big Little Lies'
! printf '%s\n' "$output" | grep -q 'Keeper'
[ -f "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo" ]
}
@test "reap --apply moves ghosts to the SSD trash and keeps live metadata" {
tmp="$(mktemp -d)"
mkdir -p "$tmp/meta/upper/Movies/Ghost (2020)" "$tmp/meta/upper/Movies/Keeper (2020)" \
"$tmp/array/Movies/Keeper (2020)"
: > "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo"
: > "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo"
run bash "$VIEW" reap --apply --upper-base "$tmp/meta" --lower "$tmp/array"
[ "$status" -eq 0 ]
[ ! -e "$tmp/meta/upper/Movies/Ghost (2020)" ]
[ -f "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo" ]
# Recoverable, not deleted: the item lands in a dated trash under the SSD base.
[ "$(find "$tmp/meta/.AGENTTRASH" -name movie.nfo | wc -l)" -eq 1 ]
}
+3
View File
@@ -402,6 +402,9 @@ apply_plan() {
moved=$(python3 -c 'import json,sys
print(sum(1 for l in open(sys.argv[1]) if json.loads(l).get("op") == "move"))' "$JOURNAL")
[ "$moved" -eq "$planned" ]
# The media servers' sidecars live in the metadata overlay and did not follow
# these moves, so the run names that cleanup at the moment it is needed.
[[ "$output" == *"media-view.sh reap"* ]]
run python3 "$APPLY" verify --plan "$OUT" --root "$LIB" --journal "$JOURNAL"
[ "$status" -eq 0 ]
}