feat(media): sweep removes the folders a completed run emptied
An apply leaves every release folder it emptied standing where it was: the live library run left 1675 husks at the root. sweep takes the same journal and deletes only folders it can prove this run emptied - a journalled move out of the folder or something under it, nothing but empty directories left, no journalled destination beneath it, not a folder the run created. A folder nobody moved cannot be selected at all, so a hand-made placeholder survives, and a symlink anywhere inside disqualifies the subtree. Every rmdir is journalled, so rollback recreates what a sweep removed.
This commit is contained in:
1 parent
272e06daf6
commit
1cb28095a7
3 files changed
+256
-17
No files matched your search
@@ -19,7 +19,7 @@ file.
|
||||
|---|---|---|
|
||||
| `scripts/media-census.py` | reads headers only | `enum`, `probed`, `plex` TSVs |
|
||||
| `scripts/media-plan.py` | no — TSVs in, TSVs out | the plan + the exception report |
|
||||
| `scripts/media-apply.py` | **renames, never copies** | the moves, plus a journal |
|
||||
| `scripts/media-apply.py` | **renames, never copies**; sweeps only what a run emptied | the moves, plus a journal |
|
||||
|
||||
The seam is `tests/media_relayout.bats`; the target contract is quoted from
|
||||
[jellyfin.org/docs/general/server/media/movies](https://jellyfin.org/docs/general/server/media/movies)
|
||||
@@ -61,17 +61,58 @@ that would be filed wrong, and the plan is not run until it is understood.
|
||||
```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 --apply
|
||||
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal …/media-relayout.jsonl
|
||||
scripts/media-apply.py rollback --journal …/media-relayout.jsonl --apply
|
||||
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal /mnt/entropy/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-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||
scripts/media-apply.py rollback --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||
```
|
||||
|
||||
After it lands, both servers need a scan or the change is invisible:
|
||||
[[Jellyfin]] *Library → Scan All Libraries*, and Plex's library scan
|
||||
(`podman exec plex …` or the web UI). `verify` reporting empty folders is normal
|
||||
for the folders the piles own; they are left in place per the deletion policy.
|
||||
An apply also leaves the release folders it emptied standing where they were —
|
||||
the live run left 1675 of them at the library root — which is what `sweep`
|
||||
removes, dry-run first, from the same journal.
|
||||
|
||||
After it lands, both servers need a scan or the change is invisible.
|
||||
|
||||
```bash
|
||||
# Run this from the host shell through `podman exec`, never from a neighbour
|
||||
# container: Plex'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.
|
||||
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]] has no credential on this box that can trigger a scan: the two keys
|
||||
in its database are Seerr's, unused since 2026-09-01, and answer 401 to every
|
||||
call. Mint one (*Dashboard → Advanced → API Keys*, Administrator) and store it in
|
||||
`~/.config/secrets/jellyfin`, or press *Library → Scan All Libraries* in the web
|
||||
UI. A scripted scan passes the key in a mode-600 curl config, never in a header
|
||||
argument:
|
||||
|
||||
```bash
|
||||
umask 077; printf 'header = "X-Emby-Token: %s"\n' "$(cat ~/.config/secrets/jellyfin)" >/tmp/jf.hdr
|
||||
curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \
|
||||
-X POST http://127.0.0.1:8096/Library/Refresh
|
||||
```
|
||||
|
||||
## Laws
|
||||
|
||||
- **The journal lives beside the library, never in `/tmp`.** The live run's
|
||||
`/mnt/entropy/media-relayout.jsonl` is the only rollback instrument; a journal
|
||||
a reboot clears is not an instrument.
|
||||
- **An apply breaks [[Plex]] playback for every moved item until its section is
|
||||
rescanned.** The database keeps the release-folder path, so streams fail
|
||||
`Couldn't find the file to stream` while the scanner works through the section.
|
||||
Trigger the scans in the same window as the apply, and never read a 404 in that
|
||||
window as a lost file — the file is where the journal says it is.
|
||||
- **Plex's matched title outranks the filename**, and a Plex year of `0` means
|
||||
*indexed but never matched* — trusting it named folders `Title (0)`, which is
|
||||
a Jellyfin item with no year, no poster and no identity. A year that is not a
|
||||
@@ -110,14 +151,22 @@ for the folders the piles own; they are left in place per the deletion policy.
|
||||
duplicate destination stops the run *before the first rename*, so the operator
|
||||
fixes one thing and re-runs instead of untangling a half-applied plan.
|
||||
`--force` relaxes only the opinion guards; it never permits overwriting.
|
||||
- **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,
|
||||
it now holds nothing but empty directories, no journalled destination lives
|
||||
beneath it, and it is not a folder the run created. A folder nobody moved —
|
||||
someone's placeholder — cannot be selected at all, and a symlink anywhere
|
||||
inside disqualifies the subtree, because the thing behind a link is not this
|
||||
tool's to delete. Every removal is journalled as `rmdir` and `rollback`
|
||||
recreates the folder, so the sweep is undone by the same command as the moves.
|
||||
|
||||
## Rollback
|
||||
|
||||
One command, from the journal of the run:
|
||||
`media-apply.py rollback --journal …/media-relayout.jsonl --apply`. It restores
|
||||
every source path, removes only the folders that run created *and that are now
|
||||
empty*, and refuses everything if any moved file cannot be accounted for. A
|
||||
rolled-back journal refuses to roll back twice.
|
||||
`media-apply.py rollback --journal …/media-relayout.jsonl --apply`. It recreates
|
||||
any folder a sweep removed, restores every source path, removes only the folders
|
||||
that run created *and that are now empty*, and refuses everything if any moved
|
||||
file cannot be accounted for. A rolled-back journal refuses to roll back twice.
|
||||
|
||||
## See Also
|
||||
|
||||
|
||||
Reference in new issue
Block a user