Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a803aa046a | ||
|
|
73f77a13c3 |
No files matched your search
+14
-2
@@ -241,6 +241,11 @@ def sweep_targets(root, journal):
|
|||||||
journalled destination lives under it. A symlink anywhere inside disqualifies
|
journalled destination lives under it. A symlink anywhere inside disqualifies
|
||||||
the whole subtree: this tool never follows a link, and deleting the folder
|
the whole subtree: this tool never follows a link, and deleting the folder
|
||||||
that holds one would delete someone's mount point.
|
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"]
|
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"}
|
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 False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return sorted((d for d in marked if removable(d)),
|
roots = [d for d in marked if removable(d)]
|
||||||
key=lambda q: (-q.count(os.sep), q))
|
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):
|
def do_sweep(args, root, journal):
|
||||||
|
|||||||
@@ -222,8 +222,9 @@ eps = collections.defaultdict(list)
|
|||||||
for r in erows:
|
for r in erows:
|
||||||
if r["path"] in marks | extras:
|
if r["path"] in marks | extras:
|
||||||
continue
|
continue
|
||||||
if eng.ep_of(r["path"]):
|
ep = eng.ep_of(r["path"])
|
||||||
eps[eng.key_of(r["title"])].append(r)
|
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_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}
|
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
|
# 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/`, "
|
"one-folder-per-movie layout; `_release-group watermarks/`, "
|
||||||
"`_unsorted files/` and `_unsorted bonus/` hold files no film claims.")
|
"`_unsorted files/` and `_unsorted bonus/` hold files no film claims.")
|
||||||
for key in tv_keys:
|
for key in tv_keys:
|
||||||
nm = "_TV needs a TV library - " + collections.Counter(
|
readmes.append(f"README in {ROOT}/{sanitize('_TV needs a TV library - ' + key)}: "
|
||||||
r["title"] for r in eps[key]).most_common(1)[0][0]
|
"these are TV episodes found in the MOVIES library; move the folder "
|
||||||
readmes.append(f"README in {ROOT}/{sanitize(nm)}: these are TV episodes found in "
|
"to a TV library source, else Jellyfin groups them wrongly as one "
|
||||||
"the MOVIES library; move the folder to a TV library source, else "
|
"movie.")
|
||||||
"Jellyfin groups them wrongly as one movie.")
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- TV
|
# --------------------------------------------------------------------- TV
|
||||||
for key in tv_keys:
|
for key in tv_keys:
|
||||||
items = eps[key]
|
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"] != "?")
|
year = collections.Counter(r["year"] for r in items if r["year"] != "?")
|
||||||
yr = year.most_common(1)[0][0] if year else None
|
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}
|
tops = {parts_of(r["path"])[0] for r in items}
|
||||||
members = sorted([r["path"] for r in items]
|
members = sorted([r["path"] for r in items]
|
||||||
+ [q for q in extras | marks if len(parts_of(q)) > 1
|
+ [q for q in extras | marks if len(parts_of(q)) > 1
|
||||||
|
|||||||
+12
-3
@@ -322,13 +322,22 @@ def key_of(title):
|
|||||||
|
|
||||||
|
|
||||||
def ep_of(path):
|
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]
|
name = os.path.splitext(os.path.basename(path))[0]
|
||||||
m = EP_RE.search(name)
|
m = EP_RE.search(name)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
show, _ = title_of(path)
|
head = name[:m.start()].strip(" ._\u2013-")
|
||||||
return show, norm(m.group(1)).replace(" ", "")
|
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):
|
def base_words(title, key):
|
||||||
|
|||||||
@@ -105,14 +105,10 @@ curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \
|
|||||||
|
|
||||||
## Laws
|
## Laws
|
||||||
|
|
||||||
- **The journal lives beside the library, never in `/tmp`.** The live run's
|
- **A moved file is a 404 in [[Plex]] until its section is rescanned.** The
|
||||||
`/mnt/entropy/media-relayout.jsonl` is the only rollback instrument; a journal
|
database keeps the release-folder path, so playback fails `Couldn't find the
|
||||||
a reboot clears is not an instrument.
|
file to stream` while the scanner works through the section. Never read such a
|
||||||
- **An apply breaks [[Plex]] playback for every moved item until its section is
|
404 as a lost file: the file is where the journal says it 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
|
- **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
|
*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
|
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
|
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`
|
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.
|
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 - <series>` for a human to move to a TV source —
|
||||||
|
the movie tool never files TV.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ setup() {
|
|||||||
at "$LIB/Alien (1979)/._Alien.1979.1080p.BluRay.x265.10bit.mkv" 220
|
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".
|
# 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
|
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)/Alien.1979.1080p.BluRay.x265.10bit.mkv" 4700
|
||||||
probe_row "$LIB/Alien (1979)/Featurettes/A Bug And A Bite - Making Alien.mkv" 2700
|
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/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/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" 6300
|
||||||
probe_row "$LIB/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 5220
|
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/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/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"
|
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.
|
# 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/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"
|
export MEDIA_ROOT="$LIB"
|
||||||
run python3 "$CENSUS" enumerate --root "$LIB" --out "$ENUM"
|
run python3 "$CENSUS" enumerate --root "$LIB" --out "$ENUM"
|
||||||
@@ -191,6 +212,37 @@ for line in open(sys.argv[1]):
|
|||||||
grep -q "UNNAMED" "$EXC"
|
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" {
|
@test "every file in the census is placed or kept — nothing is dropped" {
|
||||||
plan
|
plan
|
||||||
python3 - "$ENUM" "$OUT" <<'PY'
|
python3 - "$ENUM" "$OUT" <<'PY'
|
||||||
@@ -356,6 +408,17 @@ PY
|
|||||||
[ -d "$LIB/A Empty Placeholder" ]
|
[ -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" {
|
@test "sweep without --apply reports the husks and changes nothing" {
|
||||||
plan
|
plan
|
||||||
apply_plan
|
apply_plan
|
||||||
|
|||||||
Reference in new issue
Block a user