From a803aa046a5875bc840cc99cbd8a66b0f3919732 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 30 Oct 2026 20:18:54 -0700 Subject: [PATCH] fix(media): a sweep carries the emptied folder's own empty subfolders The live sweep removed 2271 husks and left 146 standing, every one a release folder blocked by a junk subfolder (Screens/, Sample/, a BDMV leaf) that no file ever moved out of. Eligibility was per-folder, so the blocker was never a target and its parent could never be removed. Proof now carries downward: inside a folder the journal shows emptied, every empty directory qualifies. Nothing under such a folder can hold data, and every removal is still journalled as rmdir and undone by rollback. The new test is red against the previous revision, green now. The live sweep touched no file: 8193 before, 8193 after. --- scripts/media-apply.py | 16 ++++++++++++++-- .../Dankserver/Media Relayout.md | 4 ++++ tests/media_relayout.bats | 11 +++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/media-apply.py b/scripts/media-apply.py index 751d77c..9351cfd 100755 --- a/scripts/media-apply.py +++ b/scripts/media-apply.py @@ -241,6 +241,11 @@ def sweep_targets(root, journal): journalled destination lives under it. A symlink anywhere inside disqualifies the whole subtree: this tool never follows a link, and deleting the folder that holds one would delete someone's mount point. + + Proof carries downward: once a folder qualifies, every empty directory inside + it qualifies too. A release's junk (``Screens/``, ``Sample/``) is never itself + a move source, so without that rule it holds its emptied parent on disk + forever — the parent can never be removed while the child stands. """ moves = [r for r in journal.records if r.get("op") == "move"] made = {r["dst"] for r in journal.records if r.get("op") == "mkdir"} @@ -268,8 +273,15 @@ def sweep_targets(root, journal): return False return True - return sorted((d for d in marked if removable(d)), - key=lambda q: (-q.count(os.sep), q)) + roots = [d for d in marked if removable(d)] + targets = set(roots) + for d in roots: + for dirpath, dirnames, _ in os.walk(d): + for c in dirnames: + p = os.path.join(dirpath, c) + if removable(p): + targets.add(p) + return sorted(targets, key=lambda q: (-q.count(os.sep), q)) def do_sweep(args, root, journal): diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index f9b17b2..c40ca0a 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -155,6 +155,10 @@ curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \ 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. + 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 + move source itself, so 146 of them held their emptied parents on disk after + the first sweep. - **An episode-shaped name marks a SERIES, not a film — and the series is the name BEFORE the marker.** `Monk - S05E03 - Mr. Monk and the Big Game` is series `Monk`. Keyed on the whole title instead, every episode became a group of one diff --git a/tests/media_relayout.bats b/tests/media_relayout.bats index 900c92a..bd0a614 100755 --- a/tests/media_relayout.bats +++ b/tests/media_relayout.bats @@ -408,6 +408,17 @@ PY [ -d "$LIB/A Empty Placeholder" ] } +@test "a swept folder carries the empty junk folders inside it" { + mkdir -p "$LIB/AMERICAN POP/Screens" # screenshots, never a move source + plan + apply_plan + run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply + [ "$status" -eq 0 ] + [[ "$output" == *"0 left in place"* ]] + [ ! -e "$LIB/AMERICAN POP/Screens" ] + [ ! -e "$LIB/AMERICAN POP" ] +} + @test "sweep without --apply reports the husks and changes nothing" { plan apply_plan