From 73f77a13c3dede0fd7f630a574506374982d3aa3 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 30 Oct 2026 20:12:05 -0700 Subject: [PATCH 1/2] fix(media): a series is the name before the episode marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyed on the whole episode title, every episode of a box set became a group of one and never reached the five-episode size that calls something a series, so the live run filed 124 Monk episodes as 124 movie folders, each named after its own episode. ep_of now returns the series — the name before SxxEyy / 1x04 / Part N — and the planner groups and names the quarantine pile by that series, so one show is one pile: 'Monk - S05E03 - Mr. Monk and the Big Game' is series monk. The threshold keeps its job: a lone film named Part 2 stays a film. Re-planning the live census with this moves nothing differently except the 124 Monk files, which now join the TV pile: 168 episode files across 3 series. --- scripts/media-plan.py | 17 +++--- scripts/media-titles.py | 15 ++++-- .../Dankserver/Media Relayout.md | 20 ++++--- tests/media_relayout.bats | 52 +++++++++++++++++++ 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/scripts/media-plan.py b/scripts/media-plan.py index acf0823..e123247 100755 --- a/scripts/media-plan.py +++ b/scripts/media-plan.py @@ -222,8 +222,9 @@ eps = collections.defaultdict(list) for r in erows: if r["path"] in marks | extras: continue - if eng.ep_of(r["path"]): - eps[eng.key_of(r["title"])].append(r) + ep = eng.ep_of(r["path"]) + if ep: + eps[ep[0]].append(r) # keyed by the SERIES, never the episode title tv_keys = {k for k, v in eps.items() if len(v) >= 5} tv_set = {r["path"] for k, v in eps.items() if k in tv_keys for r in v} # A folder INSIDE a movie's folder is bonus territory even when its own name is @@ -776,19 +777,17 @@ readmes.append(f"README in {ROOT}: this library was relaid out into Jellyfin's " "one-folder-per-movie layout; `_release-group watermarks/`, " "`_unsorted files/` and `_unsorted bonus/` hold files no film claims.") for key in tv_keys: - nm = "_TV needs a TV library - " + collections.Counter( - r["title"] for r in eps[key]).most_common(1)[0][0] - readmes.append(f"README in {ROOT}/{sanitize(nm)}: these are TV episodes found in " - "the MOVIES library; move the folder to a TV library source, else " - "Jellyfin groups them wrongly as one movie.") + readmes.append(f"README in {ROOT}/{sanitize('_TV needs a TV library - ' + key)}: " + "these are TV episodes found in the MOVIES library; move the folder " + "to a TV library source, else Jellyfin groups them wrongly as one " + "movie.") # --------------------------------------------------------------------- TV for key in tv_keys: items = eps[key] - title = collections.Counter(r["title"] for r in items).most_common(1)[0][0] year = collections.Counter(r["year"] for r in items if r["year"] != "?") yr = year.most_common(1)[0][0] if year else None - base = f"_TV needs a TV library - {sanitize(title)}" + (f" ({yr})" if yr else "") + base = f"_TV needs a TV library - {sanitize(key)}" + (f" ({yr})" if yr else "") tops = {parts_of(r["path"])[0] for r in items} members = sorted([r["path"] for r in items] + [q for q in extras | marks if len(parts_of(q)) > 1 diff --git a/scripts/media-titles.py b/scripts/media-titles.py index b36584f..79eb774 100755 --- a/scripts/media-titles.py +++ b/scripts/media-titles.py @@ -322,13 +322,22 @@ def key_of(title): def ep_of(path): - """(show, episode-token) when the basename carries SxxEyy / 1x04 / Part N.""" + """(series, episode-token) when the basename carries SxxEyy / 1x04 / Part N. + + The series is the name BEFORE the marker. The box-set convention writes + "Monk - S05E03 - Mr. Monk and the Big Game", and the episode title that + follows the marker is not the show's name: keying on the whole title gave + every episode of one series its own group, so a 124-episode box set never + reached the size that calls something a series and each episode was filed + as its own film. A file named only by its marker has nothing before it, so + its whole title stands in.""" name = os.path.splitext(os.path.basename(path))[0] m = EP_RE.search(name) if not m: return None - show, _ = title_of(path) - return show, norm(m.group(1)).replace(" ", "") + head = name[:m.start()].strip(" ._\u2013-") + series = title_of(head)[0] if head else None + return series or title_of(path)[0], norm(m.group(1)).replace(" ", "") def base_words(title, key): diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index d476277..f9b17b2 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -105,14 +105,10 @@ curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \ ## 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. +- **A moved file is a 404 in [[Plex]] until its section is rescanned.** The + database keeps the release-folder path, so playback fails `Couldn't find the + file to stream` while the scanner works through the section. Never read such a + 404 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 @@ -159,6 +155,14 @@ 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. +- **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 + and never reached the five-episode size that calls something a series, so a + box set was filed as 124 movies in 124 folders. The threshold still earns its + keep: a lone film named `Part 2` stays a film. Episodes of a series are parked + in `_TV needs a TV library - ` for a human to move to a TV source — + the movie tool never files TV. ## Rollback diff --git a/tests/media_relayout.bats b/tests/media_relayout.bats index eba23d9..900c92a 100755 --- a/tests/media_relayout.bats +++ b/tests/media_relayout.bats @@ -71,6 +71,20 @@ setup() { at "$LIB/Alien (1979)/._Alien.1979.1080p.BluRay.x265.10bit.mkv" 220 # A bonus file whose name is a number: never a movie folder named "1". at "$LIB/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 2700000 + # A TV box set sitting in the movie library. This one names each file + # "Show - SxxEyy - Episode Title", so the episode title comes AFTER the + # marker; that convention is what made 124 episodes of one series look like + # 124 different films, each under its own threshold. + for e in 01 02 03 04 05; do + at "$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E$e - Mr Monk and the Rival.mkv" 3000000 + done + # The same trouble named the other way round, with no episode title at all. + for e in 01 02 03 04 05; do + at "$LIB/Big Little Lies Season 1/Big Little Lies S01E$e.mp4" 3000000 + done + # A film whose own name carries a bare "Part 2": one episode-shaped token is + # not a series, and it must not be dragged into the TV pile. + at "$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv" 9000000 probe_row "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" 4700 probe_row "$LIB/Alien (1979)/Featurettes/A Bug And A Bite - Making Alien.mkv" 2700 @@ -82,11 +96,18 @@ setup() { probe_row "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv" 5700 probe_row "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" 6300 probe_row "$LIB/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 5220 + for e in 01 02 03 04 05; do + probe_row "$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E$e - Mr Monk and the Rival.mkv" 2600 + probe_row "$LIB/Big Little Lies Season 1/Big Little Lies S01E$e.mp4" 3300 + done + probe_row "$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv" 6200 plex_row "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" "Alien" "1979" plex_row "$LIB/Marnie (1964)/Marnie.1964.1080p.BluRay.x265.mkv" "Marnie" "1964" plex_row "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv" "V/H/S/2" "2013" # An unmatched Plex row: the leaf title echoes the file and the year is 0. plex_row "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" "AMERICAN POP" "0" + plex_row "$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv" \ + "The Godfather Part 2" "1974" export MEDIA_ROOT="$LIB" run python3 "$CENSUS" enumerate --root "$LIB" --out "$ENUM" @@ -191,6 +212,37 @@ for line in open(sys.argv[1]): grep -q "UNNAMED" "$EXC" } +@test "a box set in the movie library is quarantined, not filed as movies" { + plan + local d + d="$(dst_of "$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E01 - Mr Monk and the Rival.mkv")" + [[ "$d" == "$LIB"/_TV\ needs\ a\ TV\ library\ -\ monk* ]] + d="$(dst_of "$LIB/Big Little Lies Season 1/Big Little Lies S01E01.mp4")" + [[ "$d" == "$LIB"/_TV\ needs\ a\ TV\ library\ -\ big\ little\ lies* ]] + # one series is one pile, whatever its episodes are called + run python3 -c ' +import re, sys +pile = {} +for line in open(sys.argv[1]): + f = line.rstrip("\n").split("\t") + if len(f) > 2 and f[0] == "move": + m = re.search(r"/_TV needs a TV library - ([^/]*)/", f[2] + "/") + if m: + pile[m.group(1)] = pile.get(m.group(1), 0) + 1 +for k in sorted(pile): + print(f"{k}={pile[k]}") +' "$OUT" + [ "${#lines[@]}" -eq 2 ] + [[ "${lines[0]}" == big\ little\ lies*=5 ]] + [[ "${lines[1]}" == monk*=5 ]] +} + +@test "a film named Part 2 is not called a series" { + plan + [ "$(dst_of "$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv")" \ + = "$LIB/The Godfather Part 2 (1974)/The Godfather Part 2 (1974).mkv" ] +} + @test "every file in the census is placed or kept — nothing is dropped" { plan python3 - "$ENUM" "$OUT" <<'PY' -- 2.54.0 From a803aa046a5875bc840cc99cbd8a66b0f3919732 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 30 Oct 2026 20:18:54 -0700 Subject: [PATCH 2/2] 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 -- 2.54.0