feat(media): a TV mover that will not file a show the library already has #96

Merged
dank merged 1 commits from feat/media-tv-mover into main 2026-10-30 20:31:38 -07:00
Owner

1. scripts/media-tv.py — the other half of the refusal

scripts/media-plan.py refuses to file TV: it parks episode files in a
_TV needs a TV library - <series> pile for a human. This is the mover. It
walks the movie library, groups the files whose own name carries an episode
marker by series — the name before the marker, the same key PR #95 settled
for the planner, imported from media-titles.py rather than restated — and
writes a plan in the executor's own format that places each series in the shape
both Jellyfin and Plex read without help:

<library>/<Series> (<year>)/Season <NN>/<the file's own name>.<ext>

Plan only, never a rename: media-apply.py apply owns every move, with its own
journal (media-relayout-tv.jsonl) so a TV move is not welded to the movie
relayout, and --root /mnt/entropy because the executor refuses any path
outside --root.

What it refuses, printing the reason and leaving the file alone:

  • fewer than --min (5) episode-shaped files under one series name — The Godfather Part 2 is a film, not a series;
  • an episode whose name states no season number: no invented Season 01;
  • an episode the destination library already holds at the same byte count;
  • a group whose destination two files both claim (the executor refuses the
    whole plan before its first rename — only Frank decides the keeper).

Live dry run (read-only, nothing moved yet)

Series Decision
Big Little Lies 7 episodes (S1) → TVShows/Big Little Lies/Season 01/
Leah Remini: Scientology and the Aftermath (2016) 37 episodes → new show folder
Monk (2002) 124 episodes refused — the TV library already holds them
14 shows with 1–2 episode-shaped files left in place, reported

Plan: 44 moves, 8 folders.

The Monk finding is the reason this PR exists. /mnt/entropy/Movies/Monk 2002 Seasons 1 to 8 and /mnt/entropy/TVShows/Monk 2002 Seasons 1 to 8 Complete Mixed x264 [i_c] are the same 124 episodes: same episode set (S01–S08, nothing unique
to either side), identical byte count for every one of them — ~31 GiB of
redundancy on a volume at 100 %. A mover that did not look where it was filing
would have put both copies inside the TV library. It does not delete anything:
which copy dies is Frank's call, and it is now a decision with a number on it.

Two naming defects the live run exposed, both test-first:

  • a show whose own name carries the year got it twice (X (2016) (2016)) — a
    box set stamps the year into every file name, so the folder year is dropped
    when the head already ends in one;
  • a series name that arrived lower-cased (monk) would have been filed among
    properly-cased shows — a head with no capital at all is title-cased, a name
    the publisher wrote with case is left exactly as written.

2. scripts/media-apply.py — a failed rmdir is not a removal

The gone set was built from the journal's intent records, so a folder whose
rmdir failed (read-only parent, non-empty child) was permanently blacklisted:
removable() returned False forever and no later sweep could ever finish the
job
. The live movie sweep hit exactly this — 4,517 folders targeted, 146 left
standing, and those 146 could never be removed by any subsequent run.

gone now means actually gone: a path is out of scope only if it no longer
exists. A failed intent is retried by the next sweep, which is the whole point
of running the sweep twice — the second pass is where a chmod fix-up lands.

3. Tests / evidence

tests/media_relayout.bats: 9 new tests, each red against the previous
revision before the code existed — 8 TV (shape, film-not-series, sidecar,
idempotence, doubled year, casing, duplicate-episode refusal, second encode
still moved) + 1 sweep-retry (rmdir blocked by a chmod 500 parent, then the
blocker fixed, then the second sweep finishes the job).

Full ./tests/run.sh green on this commit; the Playwright gate is unaffected
(no fleet config path touched).

4. Not in this PR — nothing is applied

No live move has run. After merge, the application is two commands on
Dankserver with the --root /mnt/entropy guard and the TV journal, then the
movie sweep again (the 146 survivors are now removable), then both library
rescans. The Monk duplicate stays exactly where it is until Frank says which
copy dies.


cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb

## 1. `scripts/media-tv.py` — the other half of the refusal `scripts/media-plan.py` refuses to file TV: it parks episode files in a `_TV needs a TV library - <series>` pile for a human. This is the mover. It walks the movie library, groups the files whose own name carries an episode marker by series — the name **before** the marker, the same key PR #95 settled for the planner, imported from `media-titles.py` rather than restated — and writes a plan in the executor's own format that places each series in the shape both Jellyfin and Plex read without help: ``` <library>/<Series> (<year>)/Season <NN>/<the file's own name>.<ext> ``` Plan only, never a rename: `media-apply.py apply` owns every move, with its own journal (`media-relayout-tv.jsonl`) so a TV move is not welded to the movie relayout, and `--root /mnt/entropy` because the executor refuses any path outside `--root`. What it refuses, printing the reason and leaving the file alone: - fewer than `--min` (5) episode-shaped files under one series name — `The Godfather Part 2` is a film, not a series; - an episode whose name states no season number: no invented `Season 01`; - an episode the **destination** library already holds at the same byte count; - a group whose destination two files both claim (the executor refuses the whole plan before its first rename — only Frank decides the keeper). ### Live dry run (read-only, nothing moved yet) |Series|Decision| |---|---| |Big Little Lies|7 episodes (S1) → `TVShows/Big Little Lies/Season 01/`| |Leah Remini: Scientology and the Aftermath (2016)|37 episodes → new show folder| |**Monk (2002)**|**124 episodes refused — the TV library already holds them**| |14 shows with 1–2 episode-shaped files|left in place, reported| Plan: **44 moves, 8 folders.** **The Monk finding is the reason this PR exists.** `/mnt/entropy/Movies/Monk 2002 Seasons 1 to 8` and `/mnt/entropy/TVShows/Monk 2002 Seasons 1 to 8 Complete Mixed x264 [i_c]` are the same 124 episodes: same episode set (S01–S08, nothing unique to either side), identical byte count for every one of them — ~31 GiB of redundancy on a volume at 100 %. A mover that did not look where it was filing would have put both copies inside the TV library. It does not delete anything: which copy dies is Frank's call, and it is now a decision with a number on it. Two naming defects the live run exposed, both test-first: - a show whose own name carries the year got it twice (`X (2016) (2016)`) — a box set stamps the year into every file name, so the folder year is dropped when the head already ends in one; - a series name that arrived lower-cased (`monk`) would have been filed among properly-cased shows — a head with no capital at all is title-cased, a name the publisher wrote with case is left exactly as written. ## 2. `scripts/media-apply.py` — a failed `rmdir` is not a removal The `gone` set was built from the journal's *intent* records, so a folder whose `rmdir` failed (read-only parent, non-empty child) was permanently blacklisted: `removable()` returned False forever and **no later sweep could ever finish the job**. The live movie sweep hit exactly this — 4,517 folders targeted, 146 left standing, and those 146 could never be removed by any subsequent run. `gone` now means *actually gone*: a path is out of scope only if it no longer exists. A failed intent is retried by the next sweep, which is the whole point of running the sweep twice — the second pass is where a `chmod` fix-up lands. ## 3. Tests / evidence `tests/media_relayout.bats`: 9 new tests, each red against the previous revision before the code existed — 8 TV (shape, film-not-series, sidecar, idempotence, doubled year, casing, duplicate-episode refusal, second encode still moved) + 1 sweep-retry (`rmdir` blocked by a `chmod 500` parent, then the blocker fixed, then the second sweep finishes the job). Full `./tests/run.sh` green on this commit; the Playwright gate is unaffected (no fleet config path touched). ## 4. Not in this PR — nothing is applied No live move has run. After merge, the application is two commands on Dankserver with the `--root /mnt/entropy` guard and the TV journal, then the movie sweep again (the 146 survivors are now removable), then both library rescans. The Monk duplicate stays exactly where it is until Frank says which copy dies. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
dank added 1 commit 2026-10-30 20:30:43 -07:00
Two fixes found by one live sweep, shipped together because the second is
what makes the first finishable.

1. scripts/media-tv.py — the other half of media-plan.py's refusal to file
   TV. Groups episode-shaped files by the series name before the marker
   (media-titles.ep_of, imported not restated), writes a plan in the
   executor's own format: <library>/<Series> (<year>)/Season <NN>/<own
   name>.<ext>. Refuses a group below --min, an episode naming no season,
   and an episode the destination library already holds at the same byte
   count — the movie share carries a second copy of all 124 Monk episodes
   the TV library has had for years; a duplicate is Frank's call, never a
   tool's copy. Applied by media-apply.py with its own journal and
   --root /mnt/entropy.

2. scripts/media-apply.py — gone meant "rmdir intended", so a folder whose
   rmdir failed was blacklisted forever and no later sweep could finish it
   (live movie sweep: 4517 targeted, 146 permanently stranded). gone now
   means "actually gone": a failed intent is retried by the next sweep.

tests: 9 new tests in media_relayout.bats, each red against the previous
revision — 8 TV + 1 sweep retry behind a chmod 500 parent. ./tests/run.sh
633 green. No live move run yet.
dank merged commit 409a8d8b0c into main 2026-10-30 20:31:38 -07:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dank/serverAppConfigs#96