Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e0d84380 | ||
|
|
988c4899d4 | ||
|
|
77e9973ff5 | ||
|
|
625263dd48 | ||
|
|
42bc482ed8 | ||
|
|
aea24353ad | ||
|
|
d1493de31a | ||
|
|
93725f42b3 | ||
|
|
f80148c5db | ||
|
|
541067e6b2 | ||
|
|
8f0b1982c2 | ||
|
|
a405c5ed35 | ||
|
|
b232033b11 | ||
|
|
4a33898a6c | ||
|
|
21c98bd485 | ||
|
|
a651c81dfb | ||
|
|
0951d909c9 | ||
|
|
69effb542d | ||
|
|
e9314ea1ee | ||
|
|
8889a4ceff | ||
|
|
f1ae1ddb36 | ||
|
|
c21c48e61e | ||
|
|
22cd17d064 |
No files matched your search
@@ -735,7 +735,12 @@ linked runbooks.
|
|||||||
**Ops**
|
**Ops**
|
||||||
|
|
||||||
- Never `pkill -f` a pattern that appears in your own command line — it kills your ssh session.
|
- Never `pkill -f` a pattern that appears in your own command line — it kills your ssh session.
|
||||||
Use `pkill -x <exact-name>`.
|
Use `pkill -x <exact-name>`, or the bracket trick — `pkill -f "serve[-]script.sh"` —
|
||||||
|
when the pattern has to be named: the bracket makes the pattern stop matching the
|
||||||
|
command line that carries it. Quote it: a zsh remote glob-expands an unquoted `[s]`
|
||||||
|
and the kill silently never runs. Re-hit 2026-11-02 —
|
||||||
|
`ssh host 'pkill -f jfshowswatch.py; …'` killed the remote shell mid-command, and
|
||||||
|
ssh exits **255 with zero output**, which reads exactly like a network fault.
|
||||||
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in committed scripts
|
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in committed scripts
|
||||||
(no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts without it.
|
(no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts without it.
|
||||||
- **ComfyUI on Dankclaw runs with GPU access** (`/dev/kfd` + `/dev/dri` via `AddDevice`,
|
- **ComfyUI on Dankclaw runs with GPU access** (`/dev/kfd` + `/dev/dri` via `AddDevice`,
|
||||||
|
|||||||
@@ -135,7 +135,18 @@ text = escape(val)
|
|||||||
if pat.search(src):
|
if pat.search(src):
|
||||||
out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1)
|
out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1)
|
||||||
else:
|
else:
|
||||||
out = src.replace("</Configuration>", f" <{elem}>{text}</{elem}>\n</Configuration>", 1)
|
# The plugin owns this file and writes it under ITS OWN root element
|
||||||
|
# (TheTVDB's is <PluginConfiguration>), and its serializer omits a
|
||||||
|
# null-valued setting entirely — so the element being filled can be
|
||||||
|
# absent outright, and a hardcoded </Configuration> anchor then matches
|
||||||
|
# nothing. Anchor on the document's real root instead.
|
||||||
|
out = src
|
||||||
|
root = re.search(r"<([A-Za-z_][\w.:-]*)[^>]*>", src)
|
||||||
|
if root and ("</%s>" % root.group(1)) in src:
|
||||||
|
name = root.group(1)
|
||||||
|
out = src.replace(
|
||||||
|
"</%s>" % name, f" <{elem}>{text}</{elem}>\n</{name}>", 1
|
||||||
|
)
|
||||||
if out == src:
|
if out == src:
|
||||||
raise SystemExit(f"{xml_path}: no place to write <{elem}>")
|
raise SystemExit(f"{xml_path}: no place to write <{elem}>")
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -121,14 +121,14 @@ class Journal:
|
|||||||
|
|
||||||
def read_plan(path, root, force):
|
def read_plan(path, root, force):
|
||||||
"""Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root."""
|
"""Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root."""
|
||||||
mkdirs, keeps, moves = [], [], []
|
mkdirs, keeps, moves, bad = [], [], [], []
|
||||||
with open(path) as fh:
|
with open(path) as fh:
|
||||||
header = fh.readline()
|
header = fh.readline()
|
||||||
if not header.startswith("action\t"):
|
if not header.startswith("action\t"):
|
||||||
raise Refuse(f"{path} is not a plan (first column is "
|
raise Refuse(f"{path} is not a plan (first column is "
|
||||||
f"{header.split(chr(9))[0]!r}, expected 'action') —"
|
f"{header.split(chr(9))[0]!r}, expected 'action') —"
|
||||||
" regenerate it with scripts/media-plan.py")
|
" regenerate it with scripts/media-plan.py")
|
||||||
for line in fh:
|
for n, line in enumerate(fh, 2):
|
||||||
f = line.rstrip("\n").split("\t")
|
f = line.rstrip("\n").split("\t")
|
||||||
if f[0] == "mkdir":
|
if f[0] == "mkdir":
|
||||||
mkdirs.append(f[2])
|
mkdirs.append(f[2])
|
||||||
@@ -136,6 +136,14 @@ def read_plan(path, root, force):
|
|||||||
keeps.append(f[1])
|
keeps.append(f[1])
|
||||||
elif f[0] == "move":
|
elif f[0] == "move":
|
||||||
moves.append((f[1], f[2], f[3] if len(f) > 3 else ""))
|
moves.append((f[1], f[2], f[3] if len(f) > 3 else ""))
|
||||||
|
else:
|
||||||
|
bad.append(f"line {n}: unknown action {f[0]!r} — this tool"
|
||||||
|
" knows mkdir, keep and move (a rename is a move)")
|
||||||
|
if bad:
|
||||||
|
# A row this tool could not read is a plan bug, and a plan bug refuses:
|
||||||
|
# silently dropping it turns a typo into a no-op run that reports success.
|
||||||
|
head = "; ".join(bad[:3])
|
||||||
|
raise Refuse(head + (f" (+{len(bad) - 3} more)" if len(bad) > 3 else ""))
|
||||||
if not force:
|
if not force:
|
||||||
for p in mkdirs + keeps + [p for m in moves for p in m[:2]]:
|
for p in mkdirs + keeps + [p for m in moves for p in m[:2]]:
|
||||||
if p and not (p == root or p.startswith(root + os.sep)):
|
if p and not (p == root or p.startswith(root + os.sep)):
|
||||||
|
|||||||
+10
-4
@@ -78,10 +78,16 @@ one journal is one undo:
|
|||||||
|
|
||||||
python3 scripts/media-shows.py --root /mnt/entropy/TVShows \
|
python3 scripts/media-shows.py --root /mnt/entropy/TVShows \
|
||||||
--enum /tmp/tv-enum.tsv --titles /tmp/jellyfin-tv.tsv \
|
--enum /tmp/tv-enum.tsv --titles /tmp/jellyfin-tv.tsv \
|
||||||
--tvdb --tmdb --out /mnt/entropy/media-tv-plan.tsv \
|
--tvdb --tmdb --out /mnt/entropy/.media-campaign/media-tv-plan.tsv \
|
||||||
--park /mnt/entropy/media-park/TVShows
|
--park /mnt/entropy/NeedsProcessing/TVShows
|
||||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv \
|
python3 scripts/media-apply.py apply \
|
||||||
--root /mnt/entropy --journal /mnt/entropy/media-tv-relayout.jsonl
|
--plan /mnt/entropy/.media-campaign/media-tv-plan.tsv \
|
||||||
|
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/media-tv-relayout.jsonl
|
||||||
|
|
||||||
|
Every plan, journal and log lives in /mnt/entropy/.media-campaign/ — the array
|
||||||
|
root holds libraries only. media-apply.py defaults its journal to
|
||||||
|
<root>/media-relayout.jsonl, so pass --journal explicitly or the run puts the
|
||||||
|
log back on the root.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import collections
|
import collections
|
||||||
|
|||||||
+13
-6
@@ -17,13 +17,20 @@ the executor's own path guard still has teeth, and give it its own journal —
|
|||||||
one journal is one undo, and a TV move must not be welded to a movie relayout:
|
one journal is one undo, and a TV move must not be welded to a movie relayout:
|
||||||
|
|
||||||
python3 scripts/media-tv.py --root /mnt/entropy/Movies \
|
python3 scripts/media-tv.py --root /mnt/entropy/Movies \
|
||||||
--library /mnt/entropy/TVShows --out /mnt/entropy/media-tv-plan.tsv
|
--library /mnt/entropy/TVShows \
|
||||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv \
|
--out /mnt/entropy/.media-campaign/media-tv-plan.tsv
|
||||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-tv.jsonl
|
python3 scripts/media-apply.py apply \
|
||||||
python3 scripts/media-apply.py verify --plan /mnt/entropy/media-tv-plan.tsv \
|
--plan /mnt/entropy/.media-campaign/media-tv-plan.tsv \
|
||||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-tv.jsonl
|
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl
|
||||||
|
python3 scripts/media-apply.py verify \
|
||||||
|
--plan /mnt/entropy/.media-campaign/media-tv-plan.tsv \
|
||||||
|
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl
|
||||||
python3 scripts/media-apply.py sweep --root /mnt/entropy \
|
python3 scripts/media-apply.py sweep --root /mnt/entropy \
|
||||||
--journal /mnt/entropy/media-relayout-tv.jsonl --apply
|
--journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl --apply
|
||||||
|
|
||||||
|
Plans and journals live in `/mnt/entropy/.media-campaign/`, never loose on the
|
||||||
|
array root: `media-apply.py` defaults its journal to `<root>/media-relayout.jsonl`,
|
||||||
|
so `--journal` is always explicit here.
|
||||||
|
|
||||||
Guards, and why each one is here:
|
Guards, and why each one is here:
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ the journal and frees no space until the hold is purged.
|
|||||||
|
|
||||||
python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
||||||
--hold /mnt/entropy/.AGENTTRASH/20261102-tv-duplicates \
|
--hold /mnt/entropy/.AGENTTRASH/20261102-tv-duplicates \
|
||||||
--out /mnt/entropy/tv-dupes-plan.tsv
|
--out /mnt/entropy/.media-campaign/tv-dupes-plan.tsv
|
||||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/tv-dupes-plan.tsv \
|
python3 scripts/media-apply.py apply \
|
||||||
--root /mnt/entropy --journal /mnt/entropy/tv-dupes.jsonl \
|
--plan /mnt/entropy/.media-campaign/tv-dupes-plan.tsv \
|
||||||
|
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/tv-dupes.jsonl \
|
||||||
--metadata-view /var/lib/media-meta/view --apply
|
--metadata-view /var/lib/media-meta/view --apply
|
||||||
python3 scripts/media-apply.py verify --plan /mnt/entropy/tv-dupes-plan.tsv \
|
python3 scripts/media-apply.py verify \
|
||||||
--root /mnt/entropy --journal /mnt/entropy/tv-dupes.jsonl
|
--plan /mnt/entropy/.media-campaign/tv-dupes-plan.tsv \
|
||||||
|
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/tv-dupes.jsonl
|
||||||
python3 scripts/media-apply.py sweep --root /mnt/entropy \
|
python3 scripts/media-apply.py sweep --root /mnt/entropy \
|
||||||
--journal /mnt/entropy/tv-dupes.jsonl --apply # empty folders only
|
--journal /mnt/entropy/.media-campaign/tv-dupes.jsonl --apply # empty folders only
|
||||||
scripts/media-view.sh reap # ghost sidecars above
|
scripts/media-view.sh reap # ghost sidecars above
|
||||||
|
|
||||||
Then both servers have to be told: the plan moves files on the raw array, and no
|
Then both servers have to be told: the plan moves files on the raw array, and no
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ manager) — **Internal-only:** LAN address, no vhost, no DNS name.
|
|||||||
| Self-healing | `Restart=always`, `RestartSec=5`, `WantedBy=default.target` |
|
| Self-healing | `Restart=always`, `RestartSec=5`, `WantedBy=default.target` |
|
||||||
| Deploy | `./scripts/deploy.sh dankserver --service audiobookshelf` |
|
| Deploy | `./scripts/deploy.sh dankserver --service audiobookshelf` |
|
||||||
| Handler | `deploy_audiobookshelf` in `scripts/deploy-remote.sh` |
|
| Handler | `deploy_audiobookshelf` in `scripts/deploy-remote.sh` |
|
||||||
| Library layout | one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at `/mnt/entropy/media-relayout-audiobooks.jsonl` |
|
| Library layout | one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at `/mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl` |
|
||||||
|
|
||||||
## Bootstrap
|
## Bootstrap
|
||||||
|
|
||||||
@@ -65,6 +65,22 @@ never `/healthcheck`. It also reads files that are genuinely broken and logs
|
|||||||
`[AudioFileScanner] Invalid data found when processing input` per file — that is
|
`[AudioFileScanner] Invalid data found when processing input` per file — that is
|
||||||
the file, not the bind: the same line count grows while the total climbs.
|
the file, not the bind: the same line count grows while the total climbs.
|
||||||
|
|
||||||
|
## Phone app
|
||||||
|
|
||||||
|
The official **Audiobookshelf** app connects with Server Address
|
||||||
|
`http://192.168.0.235:8000`, username `frank`, and the same password as the web UI —
|
||||||
|
no base-path prefix, plain HTTP. Verified from a second host 2026-11-03: `/healthcheck`
|
||||||
|
and `/ping` answer 200 across the LAN and `iptables -S INPUT` carries no rule for 8000,
|
||||||
|
so a phone on `192.168.0.0/24` connects with no further setup; `download: true` on the
|
||||||
|
root account is what makes offline listening work. Away from the LAN it cannot connect —
|
||||||
|
see the LAN-only law, and note that the guard chain is not the remedy there.
|
||||||
|
|
||||||
|
**The login token is at `user.token`, not top level.** `POST /login` answers
|
||||||
|
`{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits
|
||||||
|
inside the user object. A client that reads `response.token` gets the empty string, sends
|
||||||
|
`Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401`
|
||||||
|
as though the password were wrong.
|
||||||
|
|
||||||
## Laws
|
## Laws
|
||||||
|
|
||||||
- **Speak the image's env names, not the README's.** The server reads `PORT`,
|
- **Speak the image's env names, not the README's.** The server reads `PORT`,
|
||||||
@@ -108,7 +124,10 @@ the file, not the bind: the same line count grows while the total climbs.
|
|||||||
- **LAN-only until Frank says otherwise.** `0.0.0.0:8000` with no reverse proxy
|
- **LAN-only until Frank says otherwise.** `0.0.0.0:8000` with no reverse proxy
|
||||||
in front. Any public vhost requires the Anubis + Authentik chain first, and the
|
in front. Any public vhost requires the Anubis + Authentik chain first, and the
|
||||||
outpost proxy provider has to exist before the vhost flips (external exposure
|
outpost proxy provider has to exist before the vhost flips (external exposure
|
||||||
policy in `AGENTS.md`).
|
policy in `AGENTS.md`). That chain serves browsers: the phone app is a non-browser
|
||||||
|
client and cannot solve a challenge, so guarding a vhost does not make remote
|
||||||
|
listening work — remote listening wants a VPN into the LAN, after which the LAN
|
||||||
|
address in *Phone app* is correct unchanged.
|
||||||
- **There is no `/setup` route; the creation door is `POST /init`.** `/status`
|
- **There is no `/setup` route; the creation door is `POST /init`.** `/status`
|
||||||
(no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with
|
(no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with
|
||||||
`{"newRoot":{"username","password"}}` creates it, `POST /login` returns the
|
`{"newRoot":{"username","password"}}` creates it, `POST /login` returns the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: reference
|
|||||||
title: Jellyfin
|
title: Jellyfin
|
||||||
description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status."
|
description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status."
|
||||||
tags: [jellyfin, dankserver, media-server]
|
tags: [jellyfin, dankserver, media-server]
|
||||||
timestamp: 2026-10-30T00:00:00Z
|
timestamp: 2026-11-02T11:00:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Jellyfin
|
# Jellyfin
|
||||||
@@ -58,9 +58,9 @@ reads — movie art has never existed beside the media on either layer.
|
|||||||
**Library coverage is the first thing to check when a title is missing.** The
|
**Library coverage is the first thing to check when a title is missing.** The
|
||||||
four libraries are `movies → /data/Movies`, `tvshows → /data/TVShows`,
|
four libraries are `movies → /data/Movies`, `tvshows → /data/TVShows`,
|
||||||
`music → /data/Music`, `boxsets → /config/data/collections`
|
`music → /data/Music`, `boxsets → /config/data/collections`
|
||||||
(`GET /Library/VirtualFolders`). `/data/media-park` is in **none** of them, so
|
(`GET /Library/VirtualFolders`). The review pile — `/data/NeedsProcessing`, the
|
||||||
everything filed under it — the `_unsorted` release bundles and the `extras/`
|
former `media-park`, drained into it 2026-11-02 — is in **none** of them, so
|
||||||
folders the relayout wrote there — is invisible to Jellyfin by configuration,
|
everything filed there is invisible to Jellyfin by configuration,
|
||||||
not by fault: a recursive refresh answers 204, burns CPU for minutes and
|
not by fault: a recursive refresh answers 204, burns CPU for minutes and
|
||||||
indexes nothing, because it never looks there. Check coverage before re-scanning.
|
indexes nothing, because it never looks there. Check coverage before re-scanning.
|
||||||
|
|
||||||
@@ -81,21 +81,27 @@ so an empty `<ImageFetchers />` switches art off while `ImageFetcherOrder` stays
|
|||||||
fully populated — order is not enablement — and the populated `<MetadataFetchers>`
|
fully populated — order is not enablement — and the populated `<MetadataFetchers>`
|
||||||
built next to it makes the library look healthy.
|
built next to it makes the library look healthy.
|
||||||
|
|
||||||
**Live posture, measured 2026-11-02** from `GET /Library/VirtualFolders`. The DTO is
|
**Live posture, measured 2026-11-02** from `GET /Library/VirtualFolders`, cross-checked
|
||||||
PascalCase — `Name`, `CollectionType`, `Locations`, `ItemId`, `RefreshStatus` — and a
|
against each library's `config/root/default/<Library>/options.xml`. The switches are NOT
|
||||||
census written against the 10.x camelCase keys reads `None` for everything, which
|
top-level DTO keys: the folder carries `Name`, `CollectionType`, `Locations`, `ItemId`,
|
||||||
looks exactly like an unconfigured library.
|
`PrimaryImageItemId`, `RefreshStatus` and `LibraryOptions`, and every per-library option
|
||||||
|
sits inside `LibraryOptions`. Reading `EnableInternetProviders` off the folder itself
|
||||||
|
answers `None` for all four — the camelCase trap one level deeper, and it looks exactly
|
||||||
|
like an unconfigured library.
|
||||||
|
|
||||||
| Library | `CollectionType` | `EnableInternetProviders` | `EnableRealtimeMonitor` |
|
| Library | `CollectionType` | `EnableInternetProviders` | `EnableRealtimeMonitor` |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `Movies` | movies | true (fixed 2026-11-02) | **false** |
|
| `Movies` | movies | true (fixed 2026-11-02) | **false** |
|
||||||
| `Shows` | tvshows | **false** | true |
|
| `Shows` | tvshows | true (fixed 2026-11-02) | true |
|
||||||
| `Music` | music | **false** | true |
|
| `Music` | music | **false** | true |
|
||||||
| `Collections` | boxsets | **false** | false |
|
| `Collections` | boxsets | **false** | false |
|
||||||
|
|
||||||
`Shows` and `Music` are silent file indexes in the sense above: a refresh answers
|
`Music` and `Collections` are silent file indexes in the sense above: a refresh answers
|
||||||
`204`, stores nothing, logs nothing. Any metadata or artwork work on either one
|
`204`, stores nothing, logs nothing. Any metadata or artwork work on either one
|
||||||
starts by flipping the switch, never by firing a scan.
|
starts by flipping the switch, never by firing a scan. `Shows` sat in that set
|
||||||
|
until 2026-11-02; the door is `POST /Library/VirtualFolders/LibraryOptions` with
|
||||||
|
the library's own `ItemId` (below), and `scripts/jellyfin-providers.sh verify` is
|
||||||
|
the one command that says which libraries are still in it.
|
||||||
|
|
||||||
**The per-library option door on 12.x is `POST /Library/VirtualFolders/LibraryOptions`**
|
**The per-library option door on 12.x is `POST /Library/VirtualFolders/LibraryOptions`**
|
||||||
— `GET` there answers `405`, which is how the route announces itself. The 10.x doors
|
— `GET` there answers `405`, which is how the route announces itself. The 10.x doors
|
||||||
@@ -119,9 +125,41 @@ store on a build where the automatic image pass will not run at all — it is ho
|
|||||||
the Movies library got its art: 2,515 of 2,564 films carry a primary now, and the
|
the Movies library got its art: 2,515 of 2,564 films carry a primary now, and the
|
||||||
rest have none in TMDb (shorts like `Astartes`, files whose names match nothing).
|
rest have none in TMDb (shorts like `Astartes`, files whose names match nothing).
|
||||||
|
|
||||||
**API shape on 12.x:** `/Items` and `/Items/{id}` need `UserId` when
|
**The Download door's exact wire shape:** the list response carries the address in
|
||||||
authenticated by API key — without it the answer is `400 Error processing
|
`Url` — there is no `ImageUrl` field — and the POST takes camelCase query params,
|
||||||
request`, which reads exactly like an authentication failure. `MetadataRefreshMode`
|
`?type=Primary&imageUrl=<Url>`, answering `204`; the stored tag then appears in
|
||||||
|
`ImageTags`. PascalCase params, or an `imageUrl` left empty, answer `500`, which
|
||||||
|
reads as a server fault and is a field-name fault.
|
||||||
|
|
||||||
|
**`RemoteImages` answers empty for an item with no `ProviderIds`** — no provider id,
|
||||||
|
nothing to look up. Art coverage is capped by identification, not by the image door:
|
||||||
|
the items a campaign returns nothing for are the unidentified ones, and no image
|
||||||
|
retry will change that.
|
||||||
|
|
||||||
|
**A `Download` pass reports zero only when it is pinned to one provider.** A TMDb-pinned
|
||||||
|
campaign over `Shows` classified every item in 20 s, downloaded nothing, and concluded that
|
||||||
|
identification was the only lever left. The same door queried with no `Providers=` filter
|
||||||
|
answered for 16 seasons that same day — every offer from `TheTVDB`, which the pinned form
|
||||||
|
never asks — and all 16 are dressed now. A pinned answer proves one provider is empty; the
|
||||||
|
unpinned answer is the provider census, and a TV art loop that pins is simply the wrong tool.
|
||||||
|
Post-fill the artless remainder measures 86 items (80 seasons, 6 series): 61 carry no provider
|
||||||
|
id at all — the unnamed long tail (`3a BBW - Series (2010-11)`, `History Of Africa With Zeinab
|
||||||
|
Badawi`, `(Warhammer TV)`-shaped directories), where the lever is folder names or an identify
|
||||||
|
pass — and 25 hold an id and draw no offer from any provider. No image retry moves either
|
||||||
|
group, which is the half of the old conclusion that survives.
|
||||||
|
|
||||||
|
**Series and season art is fetched; episode stills are not, deliberately.** Every
|
||||||
|
episode query is a fresh provider round-trip — nothing caches per episode — measured
|
||||||
|
at ~11 s per item beside ~2 s per series on the same host, with one hit in five in a
|
||||||
|
sample. Across 42k episodes that is days of provider traffic to dress a fifth of them,
|
||||||
|
and clients display the series and season art anyway. If episode stills are ever
|
||||||
|
ordered, the loop is the same door and its log makes the pass resumable.
|
||||||
|
|
||||||
|
**API shape on 12.x:** `/Items/{id}` needs `UserId` when authenticated by API key — without
|
||||||
|
it the answer is `400 Error processing request`, which reads exactly like an authentication
|
||||||
|
failure, and it bites hardest as a *verification* read directly after a write that succeeded.
|
||||||
|
The `/Items` list route, `?Ids=<id>` included, needs no `UserId`, so verify writes there.
|
||||||
|
`MetadataRefreshMode`
|
||||||
accepts `Default` (and `Realtime`) only; `Full` and `ValidationFailure` are
|
accepts `Default` (and `Realtime`) only; `Full` and `ValidationFailure` are
|
||||||
rejected outright, and what forces the providers to re-run is
|
rejected outright, and what forces the providers to re-run is
|
||||||
`ReplaceAllMetadata=true`.
|
`ReplaceAllMetadata=true`.
|
||||||
@@ -232,9 +270,17 @@ scripts/jellyfin-providers.sh verify # plugin + key LENGTHS, never key bytes
|
|||||||
- **The homelab TVDB key is a licensed key** (its JWT claims say
|
- **The homelab TVDB key is a licensed key** (its JWT claims say
|
||||||
`community_supported: false`), so it needs **no PIN** — the optional
|
`community_supported: false`), so it needs **no PIN** — the optional
|
||||||
`TVDB_PIN=` line exists only for a subscriber-shaped key.
|
`TVDB_PIN=` line exists only for a subscriber-shaped key.
|
||||||
- **TVDB is installed and keyed but is not a metadata agent**: TMDb still
|
- **TVDB is installed but is not a metadata agent**: TMDb still identifies
|
||||||
identifies everything. Enabling TVDB as a provider would re-fetch show
|
everything. Enabling TVDB as a provider would re-fetch show metadata across
|
||||||
metadata across the library, so it is a deliberate, separate change.
|
the library, so it is a deliberate, separate change.
|
||||||
|
- **A plugin owns its own config file, under its own root, and omits null
|
||||||
|
settings** — so the element an installer wants to fill may simply not exist,
|
||||||
|
and its root is `<PluginConfiguration>`, not `<Configuration>`. TVDB's file had
|
||||||
|
no `<ApiKey>` element at all, which is why `install` refused with
|
||||||
|
`no place to write <ApiKey>` from October until the anchor fix: the secret sat
|
||||||
|
valid in `provider-keys` the whole time, and `verify`'s `MISSING key` was this
|
||||||
|
fault, not a missing key. The writer now anchors on the file's own root and
|
||||||
|
still refuses by name when a file has none.
|
||||||
- **The deployer places the keys before it restarts jellyfin**
|
- **The deployer places the keys before it restarts jellyfin**
|
||||||
(`ensure_provider_keys` in `scripts/deploy-remote.sh`) and warns instead of
|
(`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.
|
failing when `provider-keys` is absent, so a host without it still deploys.
|
||||||
@@ -284,12 +330,41 @@ anything. The fix is `systemctl --user restart jellyfin.service`, one POST after
|
|||||||
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
||||||
response code.
|
response code.
|
||||||
|
|
||||||
**A scan that must see a new folder has to be recursive.** `POST /Library/Refresh`
|
**The door that scans is `POST /Library/Refresh` (204), and `ScheduledTasks` is
|
||||||
is the incremental pass, and inotify does not cross a rootless podman bind, so
|
the progress read.** A task that has never completed answers
|
||||||
folders created on the host stay invisible to it. The move is
|
`LastTaskResult=None, EndTimeUtc=None`, and that pair is what separates "never
|
||||||
`POST /Items/{id}/Refresh?Recursive=true` (204) per library, the id from
|
ran" from "finished a moment ago" — the difference between a door that is dead
|
||||||
`GET /Library/VirtualFolders` — its field is `ItemId`, not `Id` — and that same
|
and one that merely answered `204`.
|
||||||
record's `RefreshStatus` is the progress read.
|
|
||||||
|
**The settle read is `GET /ScheduledTasks`: the row whose `Name` is exactly
|
||||||
|
`Scan Media Library`, and its `State` (`Idle`|`Running`).** `/Library/ScanStatus`
|
||||||
|
is a 404 on 12.4, so a watcher built on the old endpoint reports nothing and a
|
||||||
|
scanning server looks an idle one. Call it settled only after a `Running`
|
||||||
|
sighting followed by two consecutive `Idle`s: sub-tasks leave gaps that a single
|
||||||
|
poll reads as finished, and a census taken in one reports the pre-scan library.
|
||||||
|
|
||||||
|
**The per-library `POST /Items/{id}/Refresh` queues nothing, and the provider
|
||||||
|
switch is not the reason.** Measured twice on 2026-11-02 against `Shows`: before
|
||||||
|
the flip and again after it, the call answered `204`, the journal wrote zero lines
|
||||||
|
over the following ten minutes, and `Scan Media Library` kept its never-run
|
||||||
|
signature; a service restart changes nothing. `POST /Library/Refresh` does run —
|
||||||
|
`204`, then `Running` inside 15 s at 33 % CPU — but it walks every library, so on
|
||||||
|
this build a library-scoped metadata pass has no working door and the global one
|
||||||
|
is the only lever.
|
||||||
|
|
||||||
|
**That endpoint takes its parameters from one source.** The documented query form
|
||||||
|
(empty body) and the JSON body form (no query) each parse; supplying both at once
|
||||||
|
answers `400` with an empty body whatever the values, and `ValidationFailure` is
|
||||||
|
rejected in the body while the query form accepts it.
|
||||||
|
|
||||||
|
**`RefreshStatus` on the `GET /Library/VirtualFolders` record is not a progress
|
||||||
|
read.** It stayed `Idle` through a scan that was verifiably running at 75 %. The
|
||||||
|
record's useful field is `ItemId`, and the live library set is still the entries
|
||||||
|
whose `Locations` are non-empty.
|
||||||
|
|
||||||
|
`inotify` does not cross a rootless podman bind, so folders created on the host
|
||||||
|
stay invisible until a pass like this runs; every pass that writes into a library
|
||||||
|
ends by firing one.
|
||||||
|
|
||||||
**`Movies` has no watcher at all.** `EnableRealtimeMonitor` is per library (table in
|
**`Movies` has no watcher at all.** `EnableRealtimeMonitor` is per library (table in
|
||||||
*Data layout*) and it is false for `Movies` and `Collections`. Nothing notices a
|
*Data layout*) and it is false for `Movies` and `Collections`. Nothing notices a
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \
|
|||||||
# library too ([[Audiobookshelf]]) — otherwise it keeps rows for folders that
|
# library too ([[Audiobookshelf]]) — otherwise it keeps rows for folders that
|
||||||
# no longer exist.
|
# no longer exist.
|
||||||
scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \
|
scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \
|
||||||
--journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply
|
--journal /mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
The group is the identity Plex asserts in its own database — **(narrator, book,
|
The group is the identity Plex asserts in its own database — **(narrator, book,
|
||||||
@@ -68,7 +68,7 @@ no chapter row names, and a loose file in a folder holding two books all park un
|
|||||||
that unrelated books share, or a chapter number mistagged as the artist (`0.4`).
|
that unrelated books share, or a chapter number mistagged as the artist (`0.4`).
|
||||||
`Various Artists` is not a placeholder — that is a multi-cast read.
|
`Various Artists` is not a placeholder — that is a multi-cast read.
|
||||||
|
|
||||||
The reviewed plan lives at `/mnt/entropy/media-relayout-audiobooks-plan.tsv`; it
|
The reviewed plan lives at `/mnt/entropy/.media-campaign/media-relayout-audiobooks-plan.tsv`; it
|
||||||
applies on its own journal, behind the music writer, and then refreshes the Plex
|
applies on its own journal, behind the music writer, and then refreshes the Plex
|
||||||
section. Verify it with `--db-kind plex` — Jellyfin holds no rows here, so a
|
section. Verify it with `--db-kind plex` — Jellyfin holds no rows here, so a
|
||||||
Jellyfin pass proves nothing either way.
|
Jellyfin pass proves nothing either way.
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ executor's own format; it never moves anything itself.
|
|||||||
python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
||||||
--hold /mnt/entropy/.AGENTTRASH/<date>-tv-duplicates \
|
--hold /mnt/entropy/.AGENTTRASH/<date>-tv-duplicates \
|
||||||
--meta /var/lib/media-meta/view/TVShows --witness witness.tsv \
|
--meta /var/lib/media-meta/view/TVShows --witness witness.tsv \
|
||||||
--ids /mnt/entropy/tv-tvdb-ids.tsv \
|
--ids /mnt/entropy/.media-campaign/tv-tvdb-ids.tsv \
|
||||||
--out /mnt/entropy/tv-dupes-plan.tsv --json /tmp/tv-groups.json
|
--out /mnt/entropy/.media-campaign/tv-dupes-plan.tsv --json /tmp/tv-groups.json
|
||||||
# then media-apply.py apply / verify / sweep / rollback, exactly as for a film prune
|
# then media-apply.py apply / verify / sweep / rollback, exactly as for a film prune
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
|||||||
match, not a lookup: `BaseItems` (`Type like '%TV.Series'`) joined to
|
match, not a lookup: `BaseItems` (`Type like '%TV.Series'`) joined to
|
||||||
`BaseItemProviders` where `ProviderId = 'Tvdb'`, the `/data/TVShows/` prefix
|
`BaseItemProviders` where `ProviderId = 'Tvdb'`, the `/data/TVShows/` prefix
|
||||||
stripped to the bare top-level folder name — 616 folders, none carrying two
|
stripped to the bare top-level folder name — 616 folders, none carrying two
|
||||||
ids, written to `/mnt/entropy/tv-tvdb-ids.tsv`. Two folders with disjoint ids
|
ids, written to `/mnt/entropy/.media-campaign/tv-tvdb-ids.tsv`. Two folders with disjoint ids
|
||||||
are two shows however alike they read — *Avatar: The Last Airbender* (1999)
|
are two shows however alike they read — *Avatar: The Last Airbender* (1999)
|
||||||
beside the 2024 remake, *Millennium* (1996) beside a German thriller Jellyfin
|
beside the 2024 remake, *Millennium* (1996) beside a German thriller Jellyfin
|
||||||
had also titled *Millennium* — and a pair whose identity cannot be proven is
|
had also titled *Millennium* — and a pair whose identity cannot be proven is
|
||||||
@@ -158,12 +158,12 @@ Nothing is deleted. Every doomed copy moves into a dated hold with the same
|
|||||||
executor the relayout uses, so any file comes back:
|
executor the relayout uses, so any file comes back:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/media-apply.py apply --plan /mnt/entropy/dup-prune-similar-plan.tsv \
|
scripts/media-apply.py apply --plan /mnt/entropy/.media-campaign/dup-prune-similar-plan.tsv \
|
||||||
--root /mnt/entropy --journal /mnt/entropy/dup-prune-similar.jsonl # dry
|
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/dup-prune-similar.jsonl # dry
|
||||||
scripts/media-apply.py apply --plan … --root /mnt/entropy --journal … --apply
|
scripts/media-apply.py apply --plan … --root /mnt/entropy --journal … --apply
|
||||||
scripts/media-apply.py verify --plan … --journal /mnt/entropy/dup-prune-similar.jsonl
|
scripts/media-apply.py verify --plan … --journal /mnt/entropy/.media-campaign/dup-prune-similar.jsonl
|
||||||
scripts/media-apply.py sweep --journal /mnt/entropy/dup-prune-similar.jsonl --root /mnt/entropy --apply
|
scripts/media-apply.py sweep --journal /mnt/entropy/.media-campaign/dup-prune-similar.jsonl --root /mnt/entropy --apply
|
||||||
scripts/media-apply.py rollback --journal /mnt/entropy/dup-prune-similar.jsonl --root /mnt/entropy --apply
|
scripts/media-apply.py rollback --journal /mnt/entropy/.media-campaign/dup-prune-similar.jsonl --root /mnt/entropy --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
The plan rows are the tool's own format (`action src dst kind`, `mkdir` rows for
|
The plan rows are the tool's own format (`action src dst kind`, `mkdir` rows for
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ python3 scripts/media-music.py --root /mnt/entropy/Music --enum /tmp/music_enum.
|
|||||||
# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same
|
# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same
|
||||||
# executor, journal and rollback as [[Media Relayout]] shows, on its own journal.
|
# executor, journal and rollback as [[Media Relayout]] shows, on its own journal.
|
||||||
scripts/media-apply.py apply --plan /tmp/music_plan.tsv --root /mnt/entropy \
|
scripts/media-apply.py apply --plan /tmp/music_plan.tsv --root /mnt/entropy \
|
||||||
--journal /mnt/entropy/media-relayout-music.jsonl --apply
|
--journal /mnt/entropy/.media-campaign/media-relayout-music.jsonl --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
The group is what the tags assert, not where the files sit: **(album-artist tag,
|
The group is what the tags assert, not where the files sit: **(album-artist tag,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: reference
|
|||||||
title: Media Relayout
|
title: Media Relayout
|
||||||
description: "Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback."
|
description: "Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback."
|
||||||
tags: [dankserver, media-server, jellyfin, plex, procedure]
|
tags: [dankserver, media-server, jellyfin, plex, procedure]
|
||||||
timestamp: 2026-11-01T01:00:00Z
|
timestamp: 2026-11-02T11:00:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Media Relayout
|
# Media Relayout
|
||||||
@@ -71,12 +71,23 @@ that would be filed wrong, and the plan is not run until it is understood.
|
|||||||
```bash
|
```bash
|
||||||
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies # dry, writes nothing
|
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies # dry, writes nothing
|
||||||
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies --apply
|
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies --apply
|
||||||
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal /mnt/entropy/media-relayout.jsonl
|
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal /mnt/entropy/.media-campaign/media-relayout.jsonl
|
||||||
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies # dry
|
scripts/media-apply.py sweep --journal /mnt/entropy/.media-campaign/media-relayout.jsonl --root /mnt/entropy/Movies # dry
|
||||||
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
scripts/media-apply.py sweep --journal /mnt/entropy/.media-campaign/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||||
scripts/media-apply.py rollback --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
scripts/media-apply.py rollback --journal /mnt/entropy/.media-campaign/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Every campaign artifact — plan, journal, exceptions file, run log — lives in
|
||||||
|
`/mnt/entropy/.media-campaign/`.** The array root carries libraries and nothing
|
||||||
|
else: a root strewn with `*.jsonl` and `*-plan.tsv` gets browsed as content, and
|
||||||
|
the dot-directory is ignored by both media servers. Relocated 2026-11-02, 48 files,
|
||||||
|
undo with `media-apply.py rollback --journal /mnt/entropy/.media-campaign/media-campaign-relocate.jsonl --root /mnt/entropy --apply`.
|
||||||
|
The holds and trash (`.AGENTTRASH`, `.HOLD-<date>-*`) stay at the root on purpose:
|
||||||
|
the purge machinery is given those paths as `--hold`, and a hold that moves out
|
||||||
|
from under the tool that made it is a hold nobody can find. Name `--journal`
|
||||||
|
explicitly on every run — the tool's default is `media-relayout.jsonl` beside
|
||||||
|
`--root`, so a bare run recreates exactly the root-level log this page forbids.
|
||||||
|
|
||||||
An apply also leaves the release folders it emptied standing where they were —
|
An apply also leaves the release folders it emptied standing where they were —
|
||||||
hundreds of them at the library root — which is what `sweep` removes, dry-run
|
hundreds of them at the library root — which is what `sweep` removes, dry-run
|
||||||
first, from the same journal.
|
first, from the same journal.
|
||||||
@@ -110,7 +121,7 @@ a read beside a running scan can fail as "database disk image is malformed".
|
|||||||
`immutable` stays only the fallback for a database whose `-shm` cannot be read.
|
`immutable` stays only the fallback for a database whose `-shm` cannot be read.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/media-verify.py --journal /mnt/entropy/media-relayout-music.jsonl \
|
scripts/media-verify.py --journal /mnt/entropy/.media-campaign/media-relayout-music.jsonl \
|
||||||
--db-kind jellyfin --ignore-ext jpg,jpeg,png,log,cue,toc,nfo,txt,m3u,accurip
|
--db-kind jellyfin --ignore-ext jpg,jpeg,png,log,cue,toc,nfo,txt,m3u,accurip
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -150,11 +161,11 @@ by series, and writes a plan in the executor's own format:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/media-tv.py --root /mnt/entropy/Movies --library /mnt/entropy/TVShows \
|
scripts/media-tv.py --root /mnt/entropy/Movies --library /mnt/entropy/TVShows \
|
||||||
--out /mnt/entropy/media-tv-plan.tsv
|
--out /mnt/entropy/.media-campaign/media-tv-plan.tsv
|
||||||
scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv --root /mnt/entropy
|
scripts/media-apply.py apply --plan /mnt/entropy/.media-campaign/media-tv-plan.tsv --root /mnt/entropy
|
||||||
scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv --root /mnt/entropy \
|
scripts/media-apply.py apply --plan /mnt/entropy/.media-campaign/media-tv-plan.tsv --root /mnt/entropy \
|
||||||
--journal /mnt/entropy/media-relayout-tv.jsonl --apply
|
--journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl --apply
|
||||||
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout-tv.jsonl \
|
scripts/media-apply.py sweep --journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl \
|
||||||
--root /mnt/entropy --apply
|
--root /mnt/entropy --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -186,162 +197,10 @@ show. A name whose marker is `Part 2` is a film and stays put.
|
|||||||
|
|
||||||
## The TV library's own relayout
|
## The TV library's own relayout
|
||||||
|
|
||||||
`/mnt/entropy/TVShows` is scene packs rather than a library, and Jellyfin has
|
`/mnt/entropy/TVShows` is its own arm: a different authority (tvdb), its own
|
||||||
already matched and numbered most of it. `scripts/media-shows.py` re-files that
|
refusals, and its own test for whether a folder really holds two shows —
|
||||||
library into the shape Jellyfin reads, with **tvdb as the name authority**:
|
[[Media TV Relayout]]. It shares this page's executor, the one-journal-per-arm
|
||||||
|
law, the sweep and the rollback.
|
||||||
<Series> (<year>) [tvdbid-NNNN]/Season NN/
|
|
||||||
<Series> (<year>) - SxxEyy - <Episode Title>.ext
|
|
||||||
|
|
||||||
It never guesses: every series name, episode number and title 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 — each carries the series' TMDb
|
|
||||||
# and Tvdb ids and the title the row holds (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. So re-use
|
|
||||||
# the last one — a duration belongs to the file, not to its name: re-key the
|
|
||||||
# previous census through that pass's own journal (`src` -> `dst`) instead of
|
|
||||||
# seeking the array again. It is also the only column the film arm reads, and
|
|
||||||
# only for a file with no marker and no provider row (4,794 of 58,551 today,
|
|
||||||
# all of them covered by a re-key that costs seconds, not an hour).
|
|
||||||
# 3. The plan. --tmdb needs TMDB_API_KEY — the v3 key in ~/.config/secrets/tmdb,
|
|
||||||
# which travels as the `api_key` query parameter. Only a v4 request token is
|
|
||||||
# accepted as a Bearer header, and the wrong shape answers 401 on EVERY call,
|
|
||||||
# which the summary line flags as NOT provider-complete. --tvdb reads its
|
|
||||||
# `api_key=` line from ~/.config/secrets/tvdb, never argv. Both write the SAME
|
|
||||||
# cache file, so the plan is reproducible offline afterwards: run it once to
|
|
||||||
# fill the cache, and quote the second run.
|
|
||||||
# Paths are only strings to a dry run, so this step can run on the workstation
|
|
||||||
# against scp'd TSVs — which is where the two provider secrets live.
|
|
||||||
python3 scripts/media-shows.py --root /mnt/entropy/TVShows --enum /tmp/tv_enum.tsv \
|
|
||||||
--titles /tmp/tv_titles.tsv --probed /tmp/tv_probed.tsv \
|
|
||||||
--tmdb-cache /mnt/entropy/media-tv-names.json --tmdb --tvdb \
|
|
||||||
--park /mnt/entropy/media-park/TVShows \
|
|
||||||
--out /mnt/entropy/media-relayout-shows2-plan.tsv \
|
|
||||||
--exceptions /mnt/entropy/media-relayout-shows2-exceptions.txt
|
|
||||||
# 4. Read the exceptions file FIRST — it is the review, not a log. Same executor
|
|
||||||
# as above, but on this arm's OWN journal, NAMED PER PASS: the journal of an
|
|
||||||
# applied pass is its unwind record, so a second pass that reuses the name
|
|
||||||
# destroys the only map back.
|
|
||||||
scripts/media-apply.py apply --plan /mnt/entropy/media-relayout-shows2-plan.tsv \
|
|
||||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-shows2.jsonl --apply
|
|
||||||
```
|
|
||||||
|
|
||||||
Evidence, strongest first — the order the planner believes things:
|
|
||||||
|
|
||||||
- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`); a digit
|
|
||||||
run longer than three at that position is NOT a number — `S03E2019` and
|
|
||||||
`S04E47370355` are one box set's airdate fragment and pack index — so such a file
|
|
||||||
is renumbered by tvdb's title or parks, and is never truncated down to fit;
|
|
||||||
- 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**: which episode the file IS, and the numbering of a file
|
|
||||||
whose own name says nothing;
|
|
||||||
- the **row's tvdb id, else its TMDb id carried through TMDb's external ids, else
|
|
||||||
an exact tvdb name search**: that id names the SERIES FOLDER and supplies the
|
|
||||||
episode title, because a name the server read off a folder is not a show name.
|
|
||||||
The folder CARRIES the id: `[tvdbid-NNNN]` is what Jellyfin's Tvdb agent and
|
|
||||||
Plex's scanner both read, so a show whose title is ambiguous resolves by number
|
|
||||||
instead of by a human guessing which folder is which. The id rides the folder
|
|
||||||
only — a file name gets 180 characters and the episode title needs the room.
|
|
||||||
- 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 the movie provider's title is a run of words the FILE itself states and its
|
|
||||||
year agrees with the file's: the query is only what `title_of` salvaged from a
|
|
||||||
release name, and the first hit for a short one is a near miss — a 1963 Bond film
|
|
||||||
for the word "russia", *Special Edition I* for a file saying *Edition III*. Else it
|
|
||||||
parks.
|
|
||||||
|
|
||||||
Four refusals carry the design: what the planner will NOT do is the point of it.
|
|
||||||
|
|
||||||
- **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 season that is a YEAR is the date matcher, not a season.** Jellyfin files
|
|
||||||
specials by airdate, so a row can arrive saying `season 2002`; that is not a
|
|
||||||
season and it never becomes a folder. The one authority on such a row is tvdb's
|
|
||||||
episode list read backwards: the title the row carries names the (season,
|
|
||||||
episode) tvdb gives it. A title tvdb gives to two episodes names nothing, and
|
|
||||||
the file parks with the reason it always carried. `Season 00` IS a legal
|
|
||||||
season — specials live there — so a row that really says 0 files there instead
|
|
||||||
of parking.
|
|
||||||
- **A season tvdb's own list contradicts gets no tvdb title.** An anime pack
|
|
||||||
numbered by its release group books `S05E01` in a season tvdb does not hold, or
|
|
||||||
books more episodes there than tvdb lists: the slot its number points at is a
|
|
||||||
different episode, and dressing it in that episode's title is a confident lie.
|
|
||||||
The file still renames to the standard shape; the title does not ride, and the
|
|
||||||
series is listed under *seasons whose numbering tvdb contradicts* in the
|
|
||||||
exceptions. A season holding FEWER files than tvdb lists stays plausible — a
|
|
||||||
partial pack is the normal shape. Neither reading holds for a list tvdb
|
|
||||||
cut off at its own page limit: the default episode query answers at most
|
|
||||||
500 rows and states no total, and One Piece, Crayon Shin-chan and
|
|
||||||
Detective Conan each answer exactly 500 while holding over a thousand —
|
|
||||||
a page bounds nothing above itself, so it contradicts nothing.
|
|
||||||
|
|
||||||
**A provider name is evidence only when an id backs it.** Jellyfin names a Series
|
|
||||||
after the folder it scanned, so a library filed by release folders ends up with
|
|
||||||
one Series item per box set — `24 (2001) Season 1`, `Modern Family S05` — and
|
|
||||||
copying those names verbatim launders folder junk into the contract. The planner
|
|
||||||
resolves each series to its tvdb id and writes tvdb's name and year; the merged
|
|
||||||
identity is also what turns a box set split across five release folders into ONE
|
|
||||||
folder, and what makes two files claiming one episode MEET in the duplicate rule.
|
|
||||||
A series no id resolves keeps the provider's words and is listed under *no tvdb
|
|
||||||
identity* in the exceptions — that list, not a rename, is the review.
|
|
||||||
|
|
||||||
**The review pile lives outside the library.** Files parked inside
|
|
||||||
`/mnt/entropy/TVShows` were indexed as one show named `_unsorted`, and that
|
|
||||||
show's rows then looked like evidence. So a row whose series is the hidden
|
|
||||||
top-level folder the file itself sits in is dropped — and the folder is matched
|
|
||||||
with its leading underscore stripped, because the server *displays* `_unsorted` as
|
|
||||||
`unsorted`: compare the spelled names and the pile survives the relayout as a
|
|
||||||
visible series folder that real files get renamed into. `--park` outside the root
|
|
||||||
makes the next plan re-file the pile instead of skipping it. Inside a hidden
|
|
||||||
top-level folder a file's own words may MATCH a registered show, never invent one.
|
|
||||||
|
|
||||||
**The operator's id map is the door for a name tvdb cannot be searched by.**
|
|
||||||
`scripts/media-shows.py` reads `scripts/media-shows-tvdb-ids.tsv` by default: one
|
|
||||||
line per pack folder, `folder <TAB> tvdb id <TAB> note`. The file exists because
|
|
||||||
tvdb titles a record in the show's own language and the English name rides in
|
|
||||||
*aliases* the name search never reads: `ブラッククローバー` (331753) answers to no
|
|
||||||
spelling of "Black Clover", and the bare alias `One Piece` belongs to 392276, the
|
|
||||||
2023 live action, not to the 1999 record (81797) the pack here is. The map
|
|
||||||
outranks a provider id — a box set's provider id is the server's own guess at a
|
|
||||||
folder name — and it merges the release folders that are one show. Everything it
|
|
||||||
touched is written to the exceptions: every folder it named, every row that
|
|
||||||
matched no folder, every disagreement with a provider id, every id tvdb never
|
|
||||||
answered. A line whose id is not a number stops the plan, because a typo renames
|
|
||||||
a whole pack to the wrong show. Only folders the search CANNOT reach belong in
|
|
||||||
it; a row for a folder the search would resolve hides a live lookup behind a
|
|
||||||
number nobody re-checks.
|
|
||||||
|
|
||||||
`scripts/media-shows-tvdb.py` makes the sheet the map is built from. It reads a
|
|
||||||
plan's `mkdir` rows, asks tvdb about every destination folder carrying no
|
|
||||||
`[tvdbid-`, and writes one row per folder with the verdict that says how much was
|
|
||||||
guessed to get there: `EXACT` (tvdb's own spelling), `PUNCT` (equal only after
|
|
||||||
release junk is stripped from the folder's words), `NEAR` (tvdb answered
|
|
||||||
something else — the best hit is printed for a human and nothing applies it),
|
|
||||||
`NONE`. Acceptance never moved across the passes that built this: a hit counts
|
|
||||||
only when tvdb's name equals the query with every non-alphanumeric dropped from
|
|
||||||
both sides, and the record's year still has to fall inside any year span the
|
|
||||||
folder names. That gate is what caught three confident picks — Fairy Tail's 2024
|
|
||||||
sequel (410031), the 2026 Hokuto no Ken remake (465216) and `Mugyutto! Black
|
|
||||||
Clover` (378762) offered for Black Clover. It is why the map is approved by hand
|
|
||||||
and the sheet is never fed to the planner.
|
|
||||||
|
|
||||||
## The music and audiobook arms
|
## The music and audiobook arms
|
||||||
|
|
||||||
@@ -403,9 +262,12 @@ executor, the one-journal-per-arm law, the sweep and the rollback.
|
|||||||
- **The journal lives BESIDE the library root, never inside it** (default:
|
- **The journal lives BESIDE the library root, never inside it** (default:
|
||||||
`media-relayout.jsonl` next to the root): a log inside the library is a file
|
`media-relayout.jsonl` next to the root): a log inside the library is a file
|
||||||
Jellyfin indexes.
|
Jellyfin indexes.
|
||||||
- **Refusals are all-or-nothing.** An occupied target, a vanished source or a
|
- **Refusals are all-or-nothing.** An occupied target, a vanished source, a
|
||||||
duplicate destination stops the run *before the first rename*, so the operator
|
duplicate destination, or a plan row whose verb is not `mkdir`, `keep` or
|
||||||
fixes one thing and re-runs instead of untangling a half-applied plan.
|
`move`, stops the run *before the first rename*, so the operator fixes one
|
||||||
|
thing and re-runs instead of untangling a half-applied plan. An unread verb is
|
||||||
|
refused by line number: silently dropping one turned a pass meant to rename
|
||||||
|
two series folders into `applied: 0 moved` with exit 0 (2026-11-02).
|
||||||
`--force` relaxes only the opinion guards; it never permits overwriting.
|
`--force` relaxes only the opinion guards; it never permits overwriting.
|
||||||
- **A sweep deletes only what the journal proves the run emptied.** A folder is
|
- **A sweep deletes only what the journal proves the run emptied.** A folder is
|
||||||
eligible when a journalled file moved out of it or out of something under it,
|
eligible when a journalled file moved out of it or out of something under it,
|
||||||
@@ -432,6 +294,14 @@ executor, the one-journal-per-arm law, the sweep and the rollback.
|
|||||||
`--root` set to the share that holds BOTH libraries: the executor refuses any
|
`--root` set to the share that holds BOTH libraries: the executor refuses any
|
||||||
path outside `--root`, and a journal shared with the movie relayout would
|
path outside `--root`, and a journal shared with the movie relayout would
|
||||||
undo both runs when only one of them is wanted.
|
undo both runs when only one of them is wanted.
|
||||||
|
- **A hold folder outside `--root` forces `--force` on BOTH `apply` and
|
||||||
|
`sweep`.** `sweep` refuses the entire journal when any journalled path sits
|
||||||
|
outside the root (`this journal belongs to another library`) — the same guard
|
||||||
|
the plan hits, and it exists to stop one pass unwinding another's files.
|
||||||
|
`--force` relaxes it and stays safe there: `sweep_targets` marks only
|
||||||
|
ancestors under `--root`, so a hold folder beyond the root can never be
|
||||||
|
selected; it only blocks everything that is. The cleaner shape is the law
|
||||||
|
above — put `--root` on the share that holds both.
|
||||||
|
|
||||||
- **Sidecar metadata does not follow a rename, because it lives on another
|
- **Sidecar metadata does not follow a rename, because it lives on another
|
||||||
filesystem.** The media servers write `.nfo` and artwork through the
|
filesystem.** The media servers write `.nfo` and artwork through the
|
||||||
@@ -440,6 +310,9 @@ executor, the one-journal-per-arm law, the sweep and the rollback.
|
|||||||
`scripts/media-view.sh reap --apply` — stranded sidecars become ghost folders
|
`scripts/media-view.sh reap --apply` — stranded sidecars become ghost folders
|
||||||
that both servers browse and report as content. Whether this host mounts the
|
that both servers browse and report as content. Whether this host mounts the
|
||||||
view at all is stated on that page; `media-apply.py` stays silent when it does not.
|
view at all is stated on that page; `media-apply.py` stays silent when it does not.
|
||||||
|
A rename of folders the server never identified strands nothing: Jellyfin
|
||||||
|
writes a sidecar only for a series it recognised, so `reap` answering
|
||||||
|
`no ghosts` after such a pass is the expected result, not a broken tool.
|
||||||
- **Nothing deletes through the metadata view.** A delete there writes an overlayfs
|
- **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;
|
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
|
the file returns when the view is unmounted. Deletions in this pipeline happen
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
title: Media TV Relayout
|
||||||
|
description: "Re-file TVShows from scene packs into the shape Jellyfin reads: authority order, refusals, the two-series split test."
|
||||||
|
tags: [dankserver, media-server, jellyfin, plex, procedure]
|
||||||
|
timestamp: 2026-11-02T11:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Media TV Relayout
|
||||||
|
|
||||||
|
The `/mnt/entropy/TVShows` share arrived as scene packs — release folders whose
|
||||||
|
names are the release, not the show. This arm re-files it into the shape
|
||||||
|
[[Jellyfin]] reads, with tvdb as the name authority. The executor, the journal,
|
||||||
|
the sweep and the rollback belong to [[Media Relayout]]; this page owns the TV
|
||||||
|
authority order, the refusals, and the test that decides a split.
|
||||||
|
|
||||||
|
`/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 shape Jellyfin reads, with **tvdb as the name authority**:
|
||||||
|
|
||||||
|
<Series> (<year>) [tvdbid-NNNN]/Season NN/
|
||||||
|
<Series> (<year>) - SxxEyy - <Episode Title>.ext
|
||||||
|
|
||||||
|
It never guesses: every series name, episode number and title 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 — each carries the series' TMDb
|
||||||
|
# and Tvdb ids and the title the row holds (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. So re-use
|
||||||
|
# the last one — a duration belongs to the file, not to its name: re-key the
|
||||||
|
# previous census through that pass's own journal (`src` -> `dst`) instead of
|
||||||
|
# seeking the array again. It is also the only column the film arm reads, and
|
||||||
|
# only for a file with no marker and no provider row (4,794 of 58,551 today,
|
||||||
|
# all of them covered by a re-key that costs seconds, not an hour).
|
||||||
|
# 3. The plan. --tmdb needs TMDB_API_KEY — the v3 key in ~/.config/secrets/tmdb,
|
||||||
|
# which travels as the `api_key` query parameter. Only a v4 request token is
|
||||||
|
# accepted as a Bearer header, and the wrong shape answers 401 on EVERY call,
|
||||||
|
# which the summary line flags as NOT provider-complete. --tvdb reads its
|
||||||
|
# `api_key=` line from ~/.config/secrets/tvdb, never argv. Both write the SAME
|
||||||
|
# cache file, so the plan is reproducible offline afterwards: run it once to
|
||||||
|
# fill the cache, and quote the second run.
|
||||||
|
# Paths are only strings to a dry run, so this step can run on the workstation
|
||||||
|
# against scp'd TSVs — which is where the two provider secrets live.
|
||||||
|
python3 scripts/media-shows.py --root /mnt/entropy/TVShows --enum /tmp/tv_enum.tsv \
|
||||||
|
--titles /tmp/tv_titles.tsv --probed /tmp/tv_probed.tsv \
|
||||||
|
--tmdb-cache /mnt/entropy/.media-campaign/media-tv-names.json --tmdb --tvdb \
|
||||||
|
--park /mnt/entropy/NeedsProcessing/TVShows \
|
||||||
|
--out /mnt/entropy/.media-campaign/media-relayout-shows2-plan.tsv \
|
||||||
|
--exceptions /mnt/entropy/.media-campaign/media-relayout-shows2-exceptions.txt
|
||||||
|
# 4. Read the exceptions file FIRST — it is the review, not a log. Same executor
|
||||||
|
# as above, but on this arm's OWN journal, NAMED PER PASS: the journal of an
|
||||||
|
# applied pass is its unwind record, so a second pass that reuses the name
|
||||||
|
# destroys the only map back.
|
||||||
|
scripts/media-apply.py apply --plan /mnt/entropy/.media-campaign/media-relayout-shows2-plan.tsv \
|
||||||
|
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/media-relayout-shows2.jsonl --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence, strongest first — the order the planner believes things:
|
||||||
|
|
||||||
|
- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`); a digit
|
||||||
|
run longer than three at that position is NOT a number — `S03E2019` and
|
||||||
|
`S04E47370355` are one box set's airdate fragment and pack index — so such a file
|
||||||
|
is renumbered by tvdb's title or parks, and is never truncated down to fit;
|
||||||
|
- 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**: which episode the file IS, and the numbering of a file
|
||||||
|
whose own name says nothing;
|
||||||
|
- the **row's tvdb id, else its TMDb id carried through TMDb's external ids, else
|
||||||
|
an exact tvdb name search**: that id names the SERIES FOLDER and supplies the
|
||||||
|
episode title, because a name the server read off a folder is not a show name.
|
||||||
|
The folder CARRIES the id: `[tvdbid-NNNN]` is what Jellyfin's Tvdb agent and
|
||||||
|
Plex's scanner both read, so a show whose title is ambiguous resolves by number
|
||||||
|
instead of by a human guessing which folder is which. The id rides the folder
|
||||||
|
only — a file name gets 180 characters and the episode title needs the room.
|
||||||
|
- 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 the movie provider's title is a run of words the FILE itself states and its
|
||||||
|
year agrees with the file's: the query is only what `title_of` salvaged from a
|
||||||
|
release name, and the first hit for a short one is a near miss — a 1963 Bond film
|
||||||
|
for the word "russia", *Special Edition I* for a file saying *Edition III*. Else it
|
||||||
|
parks.
|
||||||
|
|
||||||
|
Four refusals carry the design: what the planner will NOT do is the point of it.
|
||||||
|
|
||||||
|
- **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 season that is a YEAR is the date matcher, not a season.** Jellyfin files
|
||||||
|
specials by airdate, so a row can arrive saying `season 2002`; that is not a
|
||||||
|
season and it never becomes a folder. The one authority on such a row is tvdb's
|
||||||
|
episode list read backwards: the title the row carries names the (season,
|
||||||
|
episode) tvdb gives it. A title tvdb gives to two episodes names nothing, and
|
||||||
|
the file parks with the reason it always carried. `Season 00` IS a legal
|
||||||
|
season — specials live there — so a row that really says 0 files there instead
|
||||||
|
of parking.
|
||||||
|
- **A season tvdb's own list contradicts gets no tvdb title.** An anime pack
|
||||||
|
numbered by its release group books `S05E01` in a season tvdb does not hold, or
|
||||||
|
books more episodes there than tvdb lists: the slot its number points at is a
|
||||||
|
different episode, and dressing it in that episode's title is a confident lie.
|
||||||
|
The file still renames to the standard shape; the title does not ride, and the
|
||||||
|
series is listed under *seasons whose numbering tvdb contradicts* in the
|
||||||
|
exceptions. A season holding FEWER files than tvdb lists stays plausible — a
|
||||||
|
partial pack is the normal shape. Neither reading holds for a list tvdb
|
||||||
|
cut off at its own page limit: the default episode query answers at most
|
||||||
|
500 rows and states no total, and One Piece, Crayon Shin-chan and
|
||||||
|
Detective Conan each answer exactly 500 while holding over a thousand —
|
||||||
|
a page bounds nothing above itself, so it contradicts nothing.
|
||||||
|
|
||||||
|
**A provider name is evidence only when an id backs it.** Jellyfin names a Series
|
||||||
|
after the folder it scanned, so a library filed by release folders ends up with
|
||||||
|
one Series item per box set — `24 (2001) Season 1`, `Modern Family S05` — and
|
||||||
|
copying those names verbatim launders folder junk into the contract. The planner
|
||||||
|
resolves each series to its tvdb id and writes tvdb's name and year; the merged
|
||||||
|
identity is also what turns a box set split across five release folders into ONE
|
||||||
|
folder, and what makes two files claiming one episode MEET in the duplicate rule.
|
||||||
|
A series no id resolves keeps the provider's words and is listed under *no tvdb
|
||||||
|
identity* in the exceptions — that list, not a rename, is the review.
|
||||||
|
|
||||||
|
**The review pile lives outside the library.** Files parked inside
|
||||||
|
`/mnt/entropy/TVShows` were indexed as one show named `_unsorted`, and that
|
||||||
|
show's rows then looked like evidence. So a row whose series is the hidden
|
||||||
|
top-level folder the file itself sits in is dropped — and the folder is matched
|
||||||
|
with its leading underscore stripped, because the server *displays* `_unsorted` as
|
||||||
|
`unsorted`: compare the spelled names and the pile survives the relayout as a
|
||||||
|
visible series folder that real files get renamed into. `--park` outside the root
|
||||||
|
makes the next plan re-file the pile instead of skipping it. Inside a hidden
|
||||||
|
top-level folder a file's own words may MATCH a registered show, never invent one.
|
||||||
|
|
||||||
|
**The operator's id map is the door for a name tvdb cannot be searched by.**
|
||||||
|
`scripts/media-shows.py` reads `scripts/media-shows-tvdb-ids.tsv` by default: one
|
||||||
|
line per pack folder, `folder <TAB> tvdb id <TAB> note`. The file exists because
|
||||||
|
tvdb titles a record in the show's own language and the English name rides in
|
||||||
|
*aliases* the name search never reads: `ブラッククローバー` (331753) answers to no
|
||||||
|
spelling of "Black Clover", and the bare alias `One Piece` belongs to 392276, the
|
||||||
|
2023 live action, not to the 1999 record (81797) the pack here is. The map
|
||||||
|
outranks a provider id — a box set's provider id is the server's own guess at a
|
||||||
|
folder name — and it merges the release folders that are one show. Everything it
|
||||||
|
touched is written to the exceptions: every folder it named, every row that
|
||||||
|
matched no folder, every disagreement with a provider id, every id tvdb never
|
||||||
|
answered. A line whose id is not a number stops the plan, because a typo renames
|
||||||
|
a whole pack to the wrong show. Only folders the search CANNOT reach belong in
|
||||||
|
it; a row for a folder the search would resolve hides a live lookup behind a
|
||||||
|
number nobody re-checks.
|
||||||
|
|
||||||
|
`scripts/media-shows-tvdb.py` makes the sheet the map is built from. It reads a
|
||||||
|
plan's `mkdir` rows, asks tvdb about every destination folder carrying no
|
||||||
|
`[tvdbid-`, and writes one row per folder with the verdict that says how much was
|
||||||
|
guessed to get there: `EXACT` (tvdb's own spelling), `PUNCT` (equal only after
|
||||||
|
release junk is stripped from the folder's words), `NEAR` (tvdb answered
|
||||||
|
something else — the best hit is printed for a human and nothing applies it),
|
||||||
|
`NONE`. Acceptance never moved across the passes that built this: a hit counts
|
||||||
|
only when tvdb's name equals the query with every non-alphanumeric dropped from
|
||||||
|
both sides, and the record's year still has to fall inside any year span the
|
||||||
|
folder names. That gate is what caught three confident picks — Fairy Tail's 2024
|
||||||
|
sequel (410031), the 2026 Hokuto no Ken remake (465216) and `Mugyutto! Black
|
||||||
|
Clover` (378762) offered for Black Clover. It is why the map is approved by hand
|
||||||
|
and the sheet is never fed to the planner.
|
||||||
|
|
||||||
|
**A parked folder merges into the row that owns the show.** The folder that never
|
||||||
|
got an id usually holds episodes the identified folder for that show lacks, and
|
||||||
|
the server shows the audience neither: the parked copy is invisible and the owner
|
||||||
|
reads as a short series. One `media-apply.py` plan per show, built from the
|
||||||
|
server's own rows:
|
||||||
|
|
||||||
|
- the **owner is the folder whose `Series` row carries the tvdb id**; the parked
|
||||||
|
side is the folder with the same words and no id (or the same id under a second
|
||||||
|
row — see the law below);
|
||||||
|
- the **comparison is episode identity, not filename**: which (season, episode)
|
||||||
|
the provider row gives each file, read straight out of the server. Release
|
||||||
|
folders renumber — one park carried `S01E01-11` of a show tvdb books as seasons
|
||||||
|
2 and 3, and every episode runs the same eleven minutes, so neither the marker
|
||||||
|
nor the duration says what one query into the rows says;
|
||||||
|
- **a season the owner has no file in moves whole**: `mkdir` the `Season NN`,
|
||||||
|
then one row per file with its name unchanged — the folder shape is the
|
||||||
|
contract, the file name is not;
|
||||||
|
- **a subtitle pack is re-keyed, never just moved**: a sidecar attaches only when
|
||||||
|
its stem equals the video's, so each `.srt` has to land on the OWNER's stem with
|
||||||
|
the language suffix kept (`… - The Cat and the Claw (1).en.srt`). Moved under
|
||||||
|
its release-group name it stays a folder of 88 subtitles no player ever offers;
|
||||||
|
- **the same episode on both sides is the operator's call**, never a plan row:
|
||||||
|
which copy survives is a quality judgment — the parked copy is usually the
|
||||||
|
bigger one, and a re-cut is not a worse one — and the parked file often carries
|
||||||
|
no title to align by. Those pairs are listed and left alone.
|
||||||
|
|
||||||
|
The two sides speak different path dialects, since the server records the
|
||||||
|
container coordinate ([[Jellyfin]]) and the journal speaks the array; a join that
|
||||||
|
forgets the translation returns zero rows and looks like an empty library.
|
||||||
|
|
||||||
|
## Laws of this arm
|
||||||
|
|
||||||
|
- **A folder that names two series is not evidence of two series — the files
|
||||||
|
answer.** `ffprobe -show_entries format_tag=title` names the series each file
|
||||||
|
belongs to. Measured 2026-11-02 on `COW and CHICKEN … Complete Animated TV
|
||||||
|
Series`, a bundle whose name pairs *Cow and Chicken* with *I Am Weasel*: 103
|
||||||
|
of 104 files carry a `Cow and Chicken - SxxEyy - <title>` tag and none carries
|
||||||
|
an *I Am Weasel* one. The 7-minute duration class is a half-episode segment,
|
||||||
|
not a second show, and a file holding both segments needs a remux, which this
|
||||||
|
executor will never do. Split only when the tags (or a provider episode list)
|
||||||
|
name both series per file; everything else is a rename.
|
||||||
|
- **When the numbering is right and only the name is wrong, rename the folder
|
||||||
|
and nothing else.** Tags whose `Sxx` matches the `Season NN` folder holding
|
||||||
|
them, per-season counts equal, and no duplicate `SxxEyy` key anywhere prove
|
||||||
|
the layout is already correct. Renumbering files under that evidence buys
|
||||||
|
nothing and can silently mislabel an episode.
|
||||||
|
- **One tvdb id can carry two `Series` rows, so the id-less folder list proves
|
||||||
|
nothing about a merge.** A parked folder whose words parse to a registered show
|
||||||
|
gets identified on its own and keeps its own folder: the count of id-less
|
||||||
|
folders drops, and the library now offers the same show twice. Prove a merge by
|
||||||
|
the episode rows standing under the owner folder, never by that list.
|
||||||
|
- **Move library files only while `Scan Media Library` is Idle.** A rename that
|
||||||
|
lands under a running scan is a row the server read a moment ago pointing at a
|
||||||
|
path that no longer exists. `ScheduledTasks` is the read ([[Jellyfin]]); wait
|
||||||
|
for a `Running` sighting and then two consecutive `Idle` polls, because a gap
|
||||||
|
between sub-tasks reads as finished.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
[[Media Relayout]] — the executor, the journal, the sweep and the rollback this
|
||||||
|
arm shares. [[Jellyfin]] — the reader of the resulting shape. [[Plex]] — the
|
||||||
|
second reader. [[Media Metadata View]] — where a rename's sidecars strand, or
|
||||||
|
do not.
|
||||||
|
|
||||||
|
## Citations
|
||||||
|
|
||||||
|
- Jellyfin Shows documentation — series, season and episode folder/file naming:
|
||||||
|
<https://jellyfin.org/docs/general/server/media/shows>
|
||||||
@@ -57,6 +57,7 @@ is a locator: one row, one clause, no numbers.
|
|||||||
| [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. |
|
| [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. |
|
||||||
| [[Media Music Relayout]] | Re-filing /mnt/entropy/Music by Jellyfin's tag rows: the census, the album key, the refusals. |
|
| [[Media Music Relayout]] | Re-filing /mnt/entropy/Music by Jellyfin's tag rows: the census, the album key, the refusals. |
|
||||||
| [[Media Relayout]] | Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback. |
|
| [[Media Relayout]] | Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback. |
|
||||||
|
| [[Media TV Relayout]] | Re-file TVShows from scene packs into the shape Jellyfin reads: authority order, refusals, the two-series split test. |
|
||||||
| [[Plex]] | Plex Media Server is a **containerized media server on Dankserver**: a podman |
|
| [[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. |
|
| [[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. |
|
| [[RomM]] | RomM (ROM Manager) is a self-hosted ROM library manager and player. |
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
# 3. a downloaded plugin artifact is accepted only on its pinned sha256, its
|
# 3. a downloaded plugin artifact is accepted only on its pinned sha256, its
|
||||||
# official guid and its pinned version (a swapped or downgraded artifact
|
# official guid and its pinned version (a swapped or downgraded artifact
|
||||||
# is refused before anything lands in plugins/);
|
# is refused before anything lands in plugins/);
|
||||||
# 4. the TMDb key is written into the EXISTING config with every other
|
# 4. a key is written into the EXISTING config with every other setting
|
||||||
# setting preserved — Jellyfin owns that file, and clobbering it would
|
# preserved, under whatever root that file's owner chose — Jellyfin's core
|
||||||
# silently reset country, language and adult settings;
|
# configs say `<Configuration>`, TheTVDB writes `<PluginConfiguration>` and
|
||||||
|
# omits a null setting outright, so the element to fill can be absent and
|
||||||
|
# the anchor has to come from the file; clobbering the file would silently
|
||||||
|
# reset country, language and adult settings;
|
||||||
# 5. a TVDB config the operator already saved through the UI is kept;
|
# 5. a TVDB config the operator already saved through the UI is kept;
|
||||||
# 6. install is idempotent: a second run changes no byte;
|
# 6. install is idempotent: a second run changes no byte;
|
||||||
# 7. whatever is replaced is copied to the trash first (fleet law: never
|
# 7. whatever is replaced is copied to the trash first (fleet law: never
|
||||||
@@ -254,6 +257,38 @@ XML
|
|||||||
grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg"
|
grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the key lands in a plugin-written config with no ApiKey element" {
|
||||||
|
# What TheTVDB actually writes on a fresh install: its own root element and
|
||||||
|
# no <ApiKey /> at all, because its serializer drops null settings. The
|
||||||
|
# installer used to anchor on </Configuration> and refuse this file.
|
||||||
|
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" <<XML
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||||
|
<SubscriberPIN />
|
||||||
|
<CacheDurationInHours>1</CacheDurationInHours>
|
||||||
|
<OriginalTitle>true</OriginalTitle>
|
||||||
|
</PluginConfiguration>
|
||||||
|
XML
|
||||||
|
run prov install
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qiF 'filled the ApiKey'
|
||||||
|
local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
|
||||||
|
grep -qF "<ApiKey>$TVDB_KEY</ApiKey>" "$cfg"
|
||||||
|
grep -qF '<CacheDurationInHours>1</CacheDurationInHours>' "$cfg"
|
||||||
|
grep -qF '<OriginalTitle>true</OriginalTitle>' "$cfg"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a config file with no root element is refused by name" {
|
||||||
|
# The guard still has to fire when there is genuinely nowhere to write —
|
||||||
|
# a refusal naming the file beats inventing a document shape.
|
||||||
|
printf '<?xml version="1.0" encoding="utf-8"?>\n' \
|
||||||
|
>"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
|
||||||
|
run prov install
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qF 'Jellyfin.Plugin.Tvdb.xml'
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qiF 'no place to write'
|
||||||
|
}
|
||||||
|
|
||||||
@test "a pin in the TVDB secret lands in the plugin config" {
|
@test "a pin in the TVDB secret lands in the plugin config" {
|
||||||
# A subscriber-shaped key needs the account's PIN beside it; a licensed key
|
# A subscriber-shaped key needs the account's PIN beside it; a licensed key
|
||||||
# does not, so the element appears only when the secret carries one.
|
# does not, so the element appears only when the secret carries one.
|
||||||
|
|||||||
@@ -460,6 +460,39 @@ print(sum(1 for l in open(sys.argv[1]) if json.loads(l).get("op") == "move"))' "
|
|||||||
[ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
|
[ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A hand-written plan row whose verb is not in the executor's vocabulary used
|
||||||
|
# to be dropped in silence: read_plan's if/elif chain had no else, so a plan of
|
||||||
|
# nothing but typos applied as a successful no-op (hit for real on 2026-11-02,
|
||||||
|
# when a pass meant to rename two series folders reported "applied: 0 moved"
|
||||||
|
# and exit 0). An unread row is a plan bug, and a plan bug refuses.
|
||||||
|
@test "a plan row with a verb this tool does not know stops the whole run" {
|
||||||
|
plan
|
||||||
|
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
|
||||||
|
at "$LIB/Stray/Stray.S01E01.mkv" 4000
|
||||||
|
printf 'rename\t%s\t%s\tfile\n' \
|
||||||
|
"$LIB/Stray/Stray.S01E01.mkv" "$LIB/Stray/Stray - S01E01.mkv" >> "$OUT"
|
||||||
|
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"rename"* ]]
|
||||||
|
# all-or-nothing: the rows this tool DOES understand did not run either
|
||||||
|
[ -e "$LIB/Stray/Stray.S01E01.mkv" ]
|
||||||
|
[ ! -e "$JOURNAL" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a plan whose every row is an unknown verb refuses instead of reporting success" {
|
||||||
|
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
|
||||||
|
at "$LIB/Stray/Stray.S01E01.mkv" 4000
|
||||||
|
{ printf 'action\tsrc\tdst\tkind\n'
|
||||||
|
printf 'rename\t%s\t%s\tfile\n' \
|
||||||
|
"$LIB/Stray/Stray.S01E01.mkv" "$LIB/Stray/Stray - S01E01.mkv"
|
||||||
|
} > "$BATS_TEST_TMPDIR/typo.tsv"
|
||||||
|
run python3 "$APPLY" apply --plan "$BATS_TEST_TMPDIR/typo.tsv" --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"unknown action"* ]]
|
||||||
|
[[ "$output" != *"applied"* ]]
|
||||||
|
[ -e "$LIB/Stray/Stray.S01E01.mkv" ]
|
||||||
|
}
|
||||||
|
|
||||||
@test "a run interrupted between the journal write and the move finishes the job" {
|
@test "a run interrupted between the journal write and the move finishes the job" {
|
||||||
plan
|
plan
|
||||||
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
|
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
|
||||||
|
|||||||
Reference in new issue
Block a user