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/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..c40ca0a 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,18 @@ 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 + 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..bd0a614 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' @@ -356,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