docs(media): a scan request belongs to the server's own page
Media Relayout.md hit the vault's 500-line gate the moment the tvdb contract landed, and the fat in it was not the new text: the Plex and Jellyfin scan blocks (35 lines) sat inside the relayout procedure even though they are the way you talk to those servers after ANY change, and two Laws bullets repeated the same facts a third time. Both blocks now live on [[Plex]] and [[Jellyfin]]; the relayout page keeps the two laws an operator needs at that moment — 204 is acceptance and anything else queued nothing, and one accepted refresh is the whole procedure, because a second moves `Scan Media Library` to `Cancelling` and every later 204 is discarded. Same pass, the dead numbers the vault's own law says to destroy: an applied music pass's date, an applied audiobook pass's move/park counts, and the 146 sweep stragglers of one specific first sweep. The laws they taught stay. 490 lines, `bats tests/docs_completeness.bats` 6/6 (length violation gone, link integrity clean).
This commit is contained in:
1 parent
c3ab565d9b
commit
e531e90278
3 files changed
+95
-62
No files matched your search
@@ -182,6 +182,48 @@ scripts/jellyfin-providers.sh verify # plugin + key LENGTHS, never key bytes
|
|||||||
2. Set metadata/language preferences (`LANG=en_US.UTF-8` pinned in the unit).
|
2. Set metadata/language preferences (`LANG=en_US.UTF-8` pinned in the unit).
|
||||||
3. Configure clients to use `http://192.168.0.235:8096` or the guarded vhost.
|
3. Configure clients to use `http://192.168.0.235:8096` or the guarded vhost.
|
||||||
|
|
||||||
|
## Triggering a scan
|
||||||
|
|
||||||
|
The 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, in the `MediaBrowser` form: 12.4 authenticates
|
||||||
|
only `Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token`
|
||||||
|
for every key, valid or not. 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`. The backslashes below matter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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; anything else queued no scan, so re-issue the identical
|
||||||
|
request instead of diagnosing. A `401` from a key that answers `200` on a
|
||||||
|
hand-typed request is this block's quoting, not the server: bash `printf`
|
||||||
|
collapses `\"` to `"`, curl ends the quoted value at that quote, and Jellyfin
|
||||||
|
receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answers
|
||||||
|
204, `\"` answers 401 — both run on this host).
|
||||||
|
|
||||||
|
One accepted refresh is the whole procedure. A second one while a scan runs moves
|
||||||
|
`Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
|
||||||
|
later `204` is accepted and then discarded — the status code stops meaning
|
||||||
|
anything. The fix is `systemctl --user restart jellyfin.service` (never
|
||||||
|
`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after
|
||||||
|
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
||||||
|
response code.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [[Plex]] — co-resident media server on the same volume
|
- [[Plex]] — co-resident media server on the same volume
|
||||||
|
|||||||
@@ -79,41 +79,12 @@ 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.
|
||||||
|
|
||||||
After it lands, both servers need a scan or the change is invisible.
|
After it lands, both servers need a scan or the change is invisible. [[Plex]] and
|
||||||
|
[[Jellyfin]] own the exact requests; two laws hold at this step: `204` is
|
||||||
```bash
|
acceptance and anything else queued nothing — re-issue the identical request
|
||||||
# Run this from the host shell through `podman exec`, never from a neighbour
|
rather than diagnosing — and ONE accepted refresh is the whole procedure, because
|
||||||
# container: Plex's HTTPD closes without a single byte on any request that
|
a second while a scan runs moves `Scan Media Library` to `Cancelling` and every
|
||||||
# arrives from another container's netns (10.89.x.x reads as "empty reply", not
|
later `204` is accepted and then discarded.
|
||||||
# as a 401). The token rides stdin, never argv, where any process on the host
|
|
||||||
# could read it out of /proc/<pid>/cmdline.
|
|
||||||
umask 077
|
|
||||||
podman exec plex sh -c "sed -n 's/.*PlexOnlineToken=\"\\([^\"]*\\)\".*/\\1/p' \
|
|
||||||
'/config/Library/Application Support/Plex Media Server/Preferences.xml'" >/tmp/px.tok
|
|
||||||
podman exec -i plex sh -c 'read -r T
|
|
||||||
for k in $(curl -s -H "X-Plex-Token: $T" -H "Accept: application/json" \
|
|
||||||
http://127.0.0.1:32400/library/sections | grep -o "\"key\":\"[0-9]*\"" | cut -d\" -f4); do
|
|
||||||
curl -s -o /dev/null -w "plex section $k: %{http_code}\n" -X POST \
|
|
||||||
-H "X-Plex-Token: $T" "http://127.0.0.1:32400/library/sections/$k/refresh"
|
|
||||||
done' </tmp/px.tok
|
|
||||||
```
|
|
||||||
|
|
||||||
[[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, in the `MediaBrowser` form —
|
|
||||||
Jellyfin 12.4 refuses a bare `X-Emby-Token`, and the backslashes below matter:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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; anything else queued no scan. A `401` from a key that answers `200` on a hand-typed request is this block's quoting, not the server — bash `printf` collapses `\"` to `"`, curl ends the quoted value at that quote, and Jellyfin receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answers 204, `\"` answers 401, both run on this host). One accepted refresh is the whole procedure: a second one while a scan runs moves `Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every later `204` is accepted and then dropped — the fix is `systemctl --user restart jellyfin.service` (never `deploy.sh`: that runs the metadata-view cutover), one POST after health answers, and then believe `ScheduledTasks` State `Running`, not the status code.
|
|
||||||
|
|
||||||
### Proving the move landed
|
### Proving the move landed
|
||||||
|
|
||||||
@@ -204,21 +175,30 @@ python3 scripts/media-census.py enumerate --root /mnt/entropy/TVShows \
|
|||||||
--out /tmp/tv_enum.tsv
|
--out /tmp/tv_enum.tsv
|
||||||
python3 scripts/media-census.py probe --root /mnt/entropy/TVShows \
|
python3 scripts/media-census.py probe --root /mnt/entropy/TVShows \
|
||||||
--enum /tmp/tv_enum.tsv --jobs 6 --out /tmp/tv_probed.tsv
|
--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.
|
# 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, --tvdb needs ~/.config/secrets/tvdb
|
# 3. The plan. --tmdb needs TMDB_API_KEY, --tvdb needs ~/.config/secrets/tvdb
|
||||||
# (`api_key=`, never argv); both write into the SAME cache file, so the plan is
|
# (`api_key=`, never argv); both write into the SAME cache file, so the plan is
|
||||||
# reproducible offline afterwards. Run it once to fill the cache, then again:
|
# reproducible offline afterwards. Run it once to fill the cache, then again:
|
||||||
# the second run is the one whose numbers are quoted.
|
# the second run is the one whose numbers are quoted.
|
||||||
|
# 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 \
|
python3 scripts/media-shows.py --root /mnt/entropy/TVShows --enum /tmp/tv_enum.tsv \
|
||||||
--titles /tmp/tv_titles.tsv --probed /tmp/tv_probed.tsv \
|
--titles /tmp/tv_titles.tsv --probed /tmp/tv_probed.tsv \
|
||||||
--tmdb-cache /mnt/entropy/media-tv-names.json --tmdb --tvdb \
|
--tmdb-cache /mnt/entropy/media-tv-names.json --tmdb --tvdb \
|
||||||
--park /mnt/entropy/media-park/TVShows \
|
--park /mnt/entropy/media-park/TVShows \
|
||||||
--out /mnt/entropy/media-relayout-shows-plan.tsv \
|
--out /mnt/entropy/media-relayout-shows2-plan.tsv \
|
||||||
--exceptions /mnt/entropy/media-relayout-shows-exceptions.txt
|
--exceptions /mnt/entropy/media-relayout-shows2-exceptions.txt
|
||||||
# 4. Read the exceptions file FIRST — it is the review, not a log. Same executor
|
# 4. Read the exceptions file FIRST — it is the review, not a log. Same executor
|
||||||
# as above, but on this arm's OWN journal: a shared journal cannot be unwound.
|
# as above, but on this arm's OWN journal, NAMED PER PASS: the journal of an
|
||||||
scripts/media-apply.py apply --plan /mnt/entropy/media-relayout-shows-plan.tsv \
|
# applied pass is its unwind record, so a second pass that reuses the name
|
||||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-shows.jsonl --apply
|
# 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:
|
Evidence, strongest first — the order the planner believes things:
|
||||||
@@ -318,9 +298,8 @@ What it refuses:
|
|||||||
rename to `.m4a` would claim it is; **audio no provider row names parks** too,
|
rename to `.m4a` would claim it is; **audio no provider row names parks** too,
|
||||||
because unmatched audio is a tagging job, not a renaming job.
|
because unmatched audio is a tagging job, not a renaming job.
|
||||||
|
|
||||||
The plan applies on 2026-10-31 through `/mnt/entropy/media-relayout-music.jsonl`;
|
ONE writer at a time on this array: two rename passes race, and neither journal
|
||||||
its rollback is that journal run through `media-apply.py rollback --apply`. ONE
|
tells — which is why each arm above carries its own journal name.
|
||||||
writer at a time on this array: two rename passes race, and neither journal tells.
|
|
||||||
|
|
||||||
## The audiobook library's own relayout
|
## The audiobook library's own relayout
|
||||||
|
|
||||||
@@ -376,9 +355,8 @@ 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 plan stands reviewed at `/mnt/entropy/media-relayout-audiobooks-plan.tsv`
|
This arm applies behind the music writer — one writer at a time — on its own
|
||||||
(55,114 moves, 7,764 parked, executor preflight refused nothing); it applies on its
|
journal, and then refreshes the Plex section.
|
||||||
own journal, behind the music writer, and then refreshes the Plex section.
|
|
||||||
|
|
||||||
## Laws
|
## Laws
|
||||||
|
|
||||||
@@ -419,19 +397,6 @@ own journal, behind the music writer, and then refreshes the Plex section.
|
|||||||
free space, and `rename(2)` refuses it anyway. Trash goes beside the item —
|
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
|
`/mnt/entropy/.AGENTTRASH/<date>-<topic>/` — so it costs one inode change and
|
||||||
rolls back exactly like the move that made it.
|
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.**
|
- **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`
|
`.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.
|
is not a movie. It becomes a review line telling a human to name the file.
|
||||||
@@ -460,8 +425,7 @@ own journal, behind the music writer, and then refreshes the Plex section.
|
|||||||
recreates the folder, so the sweep is undone by the same command as the moves.
|
recreates the folder, so the sweep is undone by the same command as the moves.
|
||||||
Proof carries downward: inside a folder the journal proves emptied, every
|
Proof carries downward: inside a folder the journal proves emptied, every
|
||||||
empty directory goes with it. A release's `Screens/` or `Sample/` is never a
|
empty directory goes with it. A release's `Screens/` or `Sample/` is never a
|
||||||
move source itself, so 146 of them held their emptied parents on disk after
|
move source itself, so it can hold an emptied parent until the next sweep.
|
||||||
the first sweep.
|
|
||||||
A failed `rmdir` is not a removal: only a folder that is really gone leaves
|
A failed `rmdir` is not a removal: only a folder that is really gone leaves
|
||||||
scope, so the next sweep finishes what a permission problem stopped.
|
scope, so the next sweep finishes what a permission problem stopped.
|
||||||
- **An episode-shaped name marks a SERIES, not a film — and the series is the
|
- **An episode-shaped name marks a SERIES, not a film — and the series is the
|
||||||
|
|||||||
@@ -87,3 +87,30 @@ root runs as the `dank` user, and the deployer adopts the data root with
|
|||||||
while the HTTP layer finishes loading; the first boot of a years-old
|
while the HTTP layer finishes loading; the first boot of a years-old
|
||||||
library took ~10 minutes (2026-09 cutover). The deployer gate now allows
|
library took ~10 minutes (2026-09 cutover). The deployer gate now allows
|
||||||
600 s before failing; later boots are much faster (warm page cache).
|
600 s before failing; later boots are much faster (warm page cache).
|
||||||
|
|
||||||
|
## Triggering a scan
|
||||||
|
|
||||||
|
A rename on disk is invisible until the section is rescanned, and a moved file is
|
||||||
|
a `Couldn't find the file to stream` 404 until that scan passes it. Run this from
|
||||||
|
the host shell through `podman exec`, never from a neighbour container: PMS's
|
||||||
|
HTTPD closes without a single byte on any request that arrives from another
|
||||||
|
container's netns (10.89.x.x reads as "empty reply", not as a 401). The token
|
||||||
|
rides stdin, never argv, where any process on the host could read it out of
|
||||||
|
`/proc/<pid>/cmdline`; it lives in `Preferences.xml` inside the RW data bind.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
umask 077
|
||||||
|
podman exec plex sh -c "sed -n 's/.*PlexOnlineToken=\"\\([^\"]*\\)\".*/\\1/p' \
|
||||||
|
'/config/Library/Application Support/Plex Media Server/Preferences.xml'" >/tmp/px.tok
|
||||||
|
podman exec -i plex sh -c 'read -r T
|
||||||
|
for k in $(curl -s -H "X-Plex-Token: $T" -H "Accept: application/json" \
|
||||||
|
http://127.0.0.1:32400/library/sections | grep -o "\"key\":\"[0-9]*\"" | cut -d\" -f4); do
|
||||||
|
curl -s -o /dev/null -w "plex section $k: %{http_code}\n" -X POST \
|
||||||
|
-H "X-Plex-Token: $T" "http://127.0.0.1:32400/library/sections/$k/refresh"
|
||||||
|
done' </tmp/px.tok
|
||||||
|
```
|
||||||
|
|
||||||
|
Every section answers `204` on success. Proof that a rename landed is not a scan
|
||||||
|
response — `scripts/media-verify.py` replays the run's journal against the
|
||||||
|
database, which is the only read that distinguishes "not scanned yet" from "gone";
|
||||||
|
see [[Media Relayout]].
|
||||||
Reference in new issue
Block a user