feat(media): the movie-library relayout toolchain — census, plan, executor
The movie library on the entropy volume is release-folded: the video inside
`Some.Movie.2019.1080p…-FOO/` is named after the release, and Jellyfin matches
a movie by folder and file name, so the noise cost posters, extras and versions.
Three scripts own the fix, and nothing else in the repo moves a media file.
* media-census.py the read-only inputs: a stat pass, an ffprobe header pass
(nice'd, so it is safe beside a live endpoint), and the
Plex metadata cache read that outranks any filename guess
* media-plan.py the Jellyfin-format planner: one folder per movie, the
video named exactly like the folder, extras in the
canonical subfolders, copies of one film as versions. It
opens no media file, so a whole-library plan costs seconds,
and it ends in gates — bad-names, occupied, double-claim,
left-unmoved, unplanned — that must all read zero before a
human points the executor at 24 TB
* media-apply.py the executor: rename(2) only, the intent fsynced to a
write-ahead journal BEFORE the move it authorises, so an
interrupted run is finishable and every run is undoable.
Refusals are all-or-nothing and it never overwrites a file
Laws the fixture pins (tests/media_relayout.bats, 16 tests, each proven red
against a reverted copy of the rule it guards):
* a Plex year of 0 means "indexed, never matched" — trusting it produced
folders "Title (0)": items with no year, no poster, no identity
* a Plex title carrying "/" is sanitised, or "V/H/S/2" became a five-level
tree neither matcher reads as a movie
* a folder inside a movie's folder is bonus territory even when its name is
misspelled ("Exrtas"), unless the file's own name carries another year
* matching words split on everything but a letter or digit — the earlier
tokenizer left "(1960)/extras" glued, so a film could not be found by the
folder holding it and one box set's featurettes drifted into a neighbour
* a name with no words in it is bonus material, never a movie folder named 1
Docs: the runbook page owns the procedure, the gates and the rollback; AGENTS.md
links it from the runbook index and the summary table.
This commit is contained in:
1 parent
4db985b90d
commit
272e06daf6
8 files changed
+2435
No files matched your search
Executable
+882
@@ -0,0 +1,882 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Plan a Jellyfin-format relayout of a movie library. DRY RUN ONLY: nothing moves.
|
||||
|
||||
media-plan.py [--root DIR] [--enum TSV] [--probed TSV] [--plex TSV]
|
||||
[--out TSV] [--exceptions TXT]
|
||||
|
||||
Inputs are the TSVs scripts/media-census.py exports, plus the optional Plex
|
||||
metadata-cache export whose clean titles outrank the filename parser; the title
|
||||
authority for everything Plex never matched is scripts/media-titles.py beside this
|
||||
file. No media file is opened, so planning the whole library costs seconds.
|
||||
|
||||
PLAN is a TSV the executor (scripts/media-apply.py) replays verbatim —
|
||||
`action<TAB>src<TAB>dst<TAB>kind` with action in mkdir | move | keep, ordered so
|
||||
parents exist before children. EXCEPTIONS is the human review list: every file the
|
||||
plan could not name a film for, every collision it refused, the READMEs the
|
||||
executor must write into the piles it created.
|
||||
|
||||
Target contract (jellyfin.org/docs/general/server/media/movies):
|
||||
* one folder per movie, named Title (year)
|
||||
* the video inside is named exactly like the folder
|
||||
* extra copies of one film are versions: "<base> - <label>.ext"; the base must
|
||||
match the folder name character-for-character or Jellyfin splits them apart
|
||||
* extras live in canonical subfolders (trailers, featurettes, deleted scenes,
|
||||
interviews, samples, shorts, clips, other, extras, backdrops, theme-music)
|
||||
* sidecars <base>.<lang>.<flag>.<ext>; images <base>-<poster|backdrop|...>
|
||||
* <>:"/\|?* break the matcher, so they never survive here
|
||||
* VIDEO_TS/BDMV folders support neither multiple versions nor parts
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import csv
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
|
||||
# Inputs are the census TSVs scripts/media-census.py writes; the engine beside
|
||||
# this file is the title authority. Nothing here opens a media file.
|
||||
def parse_args():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--root", default=os.environ.get("MEDIA_ROOT", "/mnt/entropy/Movies"),
|
||||
help="library root the plan moves files inside")
|
||||
ap.add_argument("--enum", default="/tmp/media-enum.tsv", help="file census TSV")
|
||||
ap.add_argument("--probed", default="/tmp/media-probed.tsv", help="ffprobe census TSV")
|
||||
ap.add_argument("--plex", default="/tmp/plex-movies.tsv",
|
||||
help="Plex metadata export (clean titles); absent = parser only")
|
||||
ap.add_argument("--out", default="/tmp/jf-plan.tsv")
|
||||
ap.add_argument("--exceptions", default="/tmp/jf-exceptions.txt")
|
||||
return ap.parse_args()
|
||||
|
||||
|
||||
ARGS = parse_args()
|
||||
ROOT = ARGS.root
|
||||
ENUM, PROBE, PLEX = ARGS.enum, ARGS.probed, ARGS.plex
|
||||
PLAN, EXCEPTS = ARGS.out, ARGS.exceptions
|
||||
ENGINE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media-titles.py")
|
||||
|
||||
spec = importlib.util.spec_from_file_location("eng", ENGINE)
|
||||
eng = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(eng)
|
||||
|
||||
VIDEO_EXT = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".ts", ".m2ts", ".mpg",
|
||||
".mpeg", ".webm", ".wmv", ".divx", ".ogm", ".flv", ".vob", ".iso",
|
||||
".img", ".mpv", ".3gp", ".rm", ".rmvb", ".m2v", ".mpv"}
|
||||
DISC_DIRS = {"BDMV", "VIDEO_TS", "CERTIFICATE", "AACS", "MLPI", "CLIPINF"}
|
||||
SUB_EXT = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
|
||||
IMG_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn"}
|
||||
AUDIO_DIR_RE = re.compile(r"soundtrack|score|\bost\b|music|song|album", re.I)
|
||||
SUB_KEEP_RE = re.compile(r"^(?!.*\d)(?!.*[._])(?!.*\[)(?!.*\()([A-Za-z][A-Za-z -]{1,24})$")
|
||||
MUSIC_TOP_RE = re.compile(r"(?i)soundtrack|score|\bost\b|music|album|song")
|
||||
SUB_DIR_OK = re.compile(r"(?i)^$|subs?|subtitles?|srt|custom ?subs?(\[.*\])?"
|
||||
r"|(additional|extra) languages|languages|resources")
|
||||
AUDIO_DIR_OK = re.compile(r"(?i)^$|audio(s)?|extra audio|additional audio"
|
||||
r"|(additional|extra) languages|commentar(y|ies)"
|
||||
r"|audio commentary|audio tracks?")
|
||||
|
||||
|
||||
AUDIO_EXT = {".mp3", ".flac", ".m4a", ".aac", ".dts", ".wav", ".ac3", ".mka",
|
||||
".opus", ".ogg", ".wma"}
|
||||
NFO_EXT = {".nfo"}
|
||||
IMG_PRIMARY = {"poster", "folder", "cover", "default", "movie", "show"}
|
||||
IMG_OTHER = {"backdrop": "backdrop", "fanart": "backdrop", "background": "backdrop",
|
||||
"banner": "banner", "logo": "logo", "clearlogo": "logo",
|
||||
"thumb": "thumb", "landscape": "thumb", "art": "backdrop",
|
||||
"clearart": "backdrop", "jacket": "poster", "cdart": "poster",
|
||||
"discart": "poster", "disc": "poster"}
|
||||
RES_RE = re.compile(r"\b(2160|4320|4k|1080|720|576|480|360|240)(i|p)\b", re.I)
|
||||
# The characters Jellyfin's matcher cannot carry in a file or folder name. "/" is
|
||||
# one of them and it is the dangerous one: Plex titles KEEP it ("V/H/S/2", and this
|
||||
# library holds six of those films), so leaving it in a name turns ONE movie folder
|
||||
# into a nested tree — Movies/V/H/S/2 (2013)/… — and Jellyfin then sees four
|
||||
# folders, none of them a movie.
|
||||
BAD_CHARS = re.compile(r'[<>:"/|?*]')
|
||||
PART_RE = re.compile(r"(?:^|[\s._-])(cd|dvd|part|pt|disc|disk|disco)"
|
||||
r"[\s._-]?([1-9abcd])(?:$|[\s._-])", re.I)
|
||||
LANGS = {"en", "eng", "it", "ita", "ja", "jpn", "de", "deu", "fr", "fra", "es",
|
||||
"spa", "pt", "por", "ru", "rus", "zh", "chi", "ko", "kor", "sv", "no",
|
||||
"da", "nl", "pl", "cs", "hu", "fi", "tr", "el", "he", "ar", "uk", "ro",
|
||||
"bg", "hr", "sr", "sk", "et", "lv", "lt", "id", "th", "vi", "fa", "la"}
|
||||
FLAGS = {"default", "forced", "foreign", "sdh", "cc"}
|
||||
NOISE = {"sub", "subs", "subtitle", "subtitles", "srt", "ass", "ext", "external",
|
||||
"the", "a", "an", "of", "and", "in", "on", "to", "for", "with", "ita",
|
||||
"eng", "english", "italian", "dual", "audio", "forced", "default"}
|
||||
# Canonical title where the library only ever carries a localised name.
|
||||
OVERRIDES = {"28 giorni dopo": "28 Days Later",
|
||||
"28 settimane dopo": "28 Weeks Later"}
|
||||
|
||||
rows = []
|
||||
for line in open(ENUM):
|
||||
size, ext, path = line.rstrip("\n").split("\t")
|
||||
rows.append({"bytes": int(size), "ext": ext.lower(), "path": path})
|
||||
ext_by = {r["path"]: r["ext"] for r in rows}
|
||||
size_by = {r["path"]: r["bytes"] for r in rows}
|
||||
|
||||
|
||||
def rel(p):
|
||||
return p[len(ROOT) + 1:] if p.startswith(ROOT + "/") else p
|
||||
|
||||
|
||||
def parts_of(p):
|
||||
return [q for q in rel(p).split("/") if q]
|
||||
|
||||
|
||||
def is_qb(p):
|
||||
return ".!q" in p.lower()
|
||||
|
||||
|
||||
def disc_root(p, fold=True):
|
||||
"""Absolute root of the disc tree containing p (the MOVIE folder that holds
|
||||
BDMV/ or VIDEO_TS/), else None. `fold` also folds bookkeeping dirs that sit
|
||||
beside the tree (CERTIFICATE/, AACS/) into the same movie folder."""
|
||||
ps = parts_of(p)
|
||||
kinds = DISC_DIRS if fold else {"BDMV", "VIDEO_TS"}
|
||||
for i, q in enumerate(ps):
|
||||
if q.upper() in kinds:
|
||||
return ROOT + "/" + "/".join(ps[:i])
|
||||
return None
|
||||
|
||||
|
||||
def row_name(r):
|
||||
"""The name a disc-tree entry should be identified by: the engine stores the
|
||||
disc's own folder for it, because "BDMV/index.bdmv" is not a title."""
|
||||
return r.get("name_src") or r["path"]
|
||||
|
||||
|
||||
def sanitize(name):
|
||||
name = BAD_CHARS.sub("-", name).replace("\\", "-")
|
||||
name = re.sub(r"\s+", " ", name).strip().strip(". ").strip()
|
||||
return (name[:180] or "untitled")
|
||||
|
||||
|
||||
def ok_year(y):
|
||||
"""True for a year a real film could carry. Plex stores 0 for a file it indexed
|
||||
but never MATCHED, and "0" is a digit string: trusting it named 67 movie
|
||||
folders "Title (0)" — Jellyfin items with no identity, no year and no poster."""
|
||||
return bool(re.fullmatch(r"(18|19|20)\d\d", y or "")) and 1870 <= int(y) <= 2035
|
||||
|
||||
|
||||
WORD_SPLIT = re.compile(r"[^0-9a-z]+")
|
||||
|
||||
|
||||
def words_of(text):
|
||||
"""The matching words of a name — or of a whole path, which is how callers
|
||||
pass a file together with the folders it sits in.
|
||||
|
||||
Split on everything that is not a letter or a digit: eng.norm does not touch
|
||||
"/", so feeding it "…/Psycho (1960)/Extras" left glued tokens like
|
||||
"(1960)/extras" and "(1954)" that no other name could ever agree with, and a
|
||||
film could not be found by the folder that holds it — which is how one box
|
||||
set's featurettes ended up in another film's folder. Both sides of every
|
||||
comparison below go through here. A bare number counts only when it could be
|
||||
a release year, so release junk ("1080", "2160", "2", "7") never poses as
|
||||
agreement between two names.
|
||||
"""
|
||||
out = set()
|
||||
for w in WORD_SPLIT.split(eng.norm(text)):
|
||||
if not w or w in eng.STOP:
|
||||
continue
|
||||
if w.isdigit() and not 1870 <= int(w) <= 2100:
|
||||
continue
|
||||
out.add(w)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Plex titles
|
||||
# Plex has matched a large part of the library's media files to real titles
|
||||
# ("Alien", not "Alien.Directors.Cut.1979.1080p.BRrip.x265…"). Where the cache
|
||||
# holds a MATCH, its title wins; the filename parser carries everything else
|
||||
# (extras, disc trees, TV, and whatever Plex never matched).
|
||||
def plex_titles():
|
||||
if not os.path.exists(PLEX):
|
||||
return {}
|
||||
out = {}
|
||||
with open(PLEX, newline="") as fh:
|
||||
for r in csv.DictReader(fh, delimiter="\t"):
|
||||
t, y = r["leaf_title"].strip(), r["leaf_year"].strip()
|
||||
# Only a match is an authority, and a match carries a year: an
|
||||
# unmatched row echoes the filename, which the parser reads better
|
||||
# than a copied release name. The junk filter stays for the matches
|
||||
# whose title still carries the release.
|
||||
if t and ok_year(y) and not re.search(
|
||||
r"2160p|1080p|x26[45]|hevc|bluray|web-?dl", t.lower()):
|
||||
out[r["file"]] = (t, y)
|
||||
return out
|
||||
|
||||
|
||||
PLEX_T = plex_titles()
|
||||
|
||||
# ------------------------------------------------------------- classification
|
||||
erows = eng.load(PROBE)
|
||||
for r in erows:
|
||||
for k, v in OVERRIDES.items():
|
||||
if k in r["title"]:
|
||||
r["title"] = v
|
||||
r["key"] = eng.key_of(r["title"])
|
||||
r["base"] = eng.base_words(r["title"], r["key"])
|
||||
marks = {r["path"] for r in erows if eng.is_watermark(r["path"], r["bytes"])}
|
||||
extras = {r["path"] for r in erows
|
||||
if r["path"] not in marks
|
||||
and eng.is_extra(r["path"], r["title"], r["dur"], ROOT,
|
||||
matched=r["path"] in PLEX_T)}
|
||||
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)
|
||||
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
|
||||
# not bonus vocabulary: "Saboteur (1942)/Exrtas/Saboteur - A Closer Look" is a
|
||||
# misspelled Extras folder, so the engine's folder rule never fired and the
|
||||
# featurette became a movie folder of its own — whose words then out-scored the
|
||||
# real Saboteur and drew that box set's other extras into it. The rule needs no
|
||||
# metadata: the folder holding the longest film owns the shorter video filed
|
||||
# under it, provided that shorter file's own name speaks the film's name — a
|
||||
# whole other film filed there stays a film, exactly as the engine's folder rule
|
||||
# exempts it. A different year in the file's own name IS that other film
|
||||
# speaking, so it vetoes the rule.
|
||||
def longest_of(rows):
|
||||
"""The longest video in each folder: that folder's feature."""
|
||||
out = {}
|
||||
for r in rows:
|
||||
if r["dur"] > 0:
|
||||
d = os.path.dirname(r["path"])
|
||||
if d not in out or r["dur"] > out[d]["dur"]:
|
||||
out[d] = r
|
||||
return out
|
||||
|
||||
|
||||
def not_disc(r):
|
||||
"""A disc tree's innards belong to the tree, never to the folder's film."""
|
||||
return not any(c in DISC_DIRS for c in r["path"].split("/"))
|
||||
|
||||
|
||||
FEATURE_OF = longest_of([r for r in erows if r["path"] not in marks and not_disc(r)])
|
||||
|
||||
|
||||
def under_a_feature(path, dur):
|
||||
"""True when a folder above this file holds a longer film that this file's
|
||||
own name names."""
|
||||
d = os.path.dirname(os.path.dirname(path))
|
||||
while len(d) > len(ROOT):
|
||||
f = FEATURE_OF.get(d)
|
||||
if f:
|
||||
if f["dur"] < dur:
|
||||
return False
|
||||
theirs = (words_of(os.path.basename(f["path"]))
|
||||
| words_of(os.path.basename(d)))
|
||||
if not words_of(os.path.basename(path)) & theirs:
|
||||
return False
|
||||
y1 = re.search(r"(?:18|19|20)\d\d", os.path.basename(path))
|
||||
y2 = re.search(r"(?:18|19|20)\d\d", os.path.basename(f["path"]))
|
||||
return not (y1 and y2 and y1.group() != y2.group())
|
||||
d = os.path.dirname(d)
|
||||
return False
|
||||
|
||||
|
||||
extras |= {r["path"] for r in erows
|
||||
if r["dur"] > 0 and r["path"] not in marks | extras | tv_set
|
||||
and not_disc(r) and under_a_feature(r["path"], r["dur"])}
|
||||
film_rows = [r for r in erows if r["path"] not in marks | extras | tv_set]
|
||||
|
||||
buckets = collections.defaultdict(list)
|
||||
for r in film_rows:
|
||||
buckets[eng.key_of(r["title"])].append(r)
|
||||
|
||||
|
||||
def res_label(r):
|
||||
m = RES_RE.search(r["res"] or "")
|
||||
if m:
|
||||
n, kind = m.group(1).lower(), m.group(2).lower()
|
||||
return "2160p" if n in ("2160", "4k") else f"{n}{kind}"
|
||||
try:
|
||||
w = int((r["res"] or "0x0").split("x")[0])
|
||||
except (ValueError, IndexError):
|
||||
w = 0
|
||||
return "2160p" if w >= 3000 else ("1080p" if w >= 1400 else "SD")
|
||||
|
||||
|
||||
def version_label(r):
|
||||
bits = [res_label(r)]
|
||||
vc = (r["vcodec"] or "").lower()
|
||||
if vc in ("av1", "libaom", "libsvtav1"):
|
||||
bits.append("AV1")
|
||||
elif vc in ("vc1", "wmv3"):
|
||||
bits.append("VC1")
|
||||
if r["bytes"] > 40 * 2**30:
|
||||
bits.append("REMUX")
|
||||
if "imax" in r["path"].lower():
|
||||
bits.append("IMAX")
|
||||
return sanitize(" ".join(bits))
|
||||
|
||||
|
||||
def group_name(items):
|
||||
titles = collections.Counter(r["title"] for r in items)
|
||||
ascii_t = [t for t in titles if t.isascii()]
|
||||
title = (collections.Counter(ascii_t) if ascii_t else titles).most_common(1)[0][0]
|
||||
years = [r["year"] for r in items if r["year"] != "?"] or \
|
||||
[r["soft"] for r in items if r["soft"]]
|
||||
year = collections.Counter(years).most_common(1)[0][0] if years else None
|
||||
return sanitize(f"{title} ({year})" if year else title)
|
||||
|
||||
|
||||
groups = []
|
||||
for key, items in buckets.items():
|
||||
for c in eng.dur_clusters(items):
|
||||
g = {"rows": c, "base": group_name(c)}
|
||||
known = [PLEX_T[r["path"]] for r in c if r["path"] in PLEX_T]
|
||||
if known:
|
||||
# one film, one name: a Plex match for any copy names every copy
|
||||
t, yr = collections.Counter(known).most_common(1)[0][0]
|
||||
g["plex"] = t
|
||||
g["base"] = sanitize(f"{t} ({yr})" if ok_year(yr) else t)
|
||||
groups.append(g)
|
||||
clash = {b for b, n in collections.Counter(g["base"] for g in groups).items() if n > 1}
|
||||
for g in groups:
|
||||
if g["base"] in clash:
|
||||
# two genuinely different films share title+year: separate folders, and
|
||||
# say so in the name, because Jellyfin can only match one item per folder
|
||||
g["base"] = sanitize(g["base"] + " - " + version_label(g["rows"][0]))
|
||||
g["flag"] = "same title and year as another film"
|
||||
|
||||
moves, mkdirs, problems, readmes = [], set(), [], []
|
||||
# --------------------------------------------------------------- unnamed groups
|
||||
# A movie folder has to READ as a title. The engine's runtime veto says any file
|
||||
# over 45 minutes that is not named like an extra is a film whatever it is called
|
||||
# — true for Jellyfin, but a 118-minute documentary named "Extra 1" then produced
|
||||
# a movie folder named "1", and folders named "1", "2", "3" are Jellyfin items
|
||||
# with no identity whose numeric keys went on to capture every subtitle named
|
||||
# "3_Eng.srt" in the library. A year proves a dated feature ("300 (2006)" is a
|
||||
# real film), so the gate is: a year, or a word that is not release noise. With
|
||||
# neither, the rows are bonus material until a human names them.
|
||||
def named(g):
|
||||
if re.search(r"\(\d{4}\)$", g["base"]):
|
||||
return True
|
||||
return any(w.isalpha() and len(w) >= 2 for w in eng.name_words(g["base"]))
|
||||
|
||||
|
||||
unnamed = [g for g in groups if not named(g)]
|
||||
groups = [g for g in groups if named(g)]
|
||||
for g in unnamed:
|
||||
for r in g["rows"]:
|
||||
extras.add(r["path"])
|
||||
problems.append(
|
||||
f"UNNAMED film {rel(r['path'])} parses to {g['base']!r} with no year: "
|
||||
f"bonus pile, not a folder — give the file a real name and re-plan")
|
||||
|
||||
|
||||
group_of = {}
|
||||
for g in groups:
|
||||
g["dir"] = f"{ROOT}/{g['base']}"
|
||||
for r in g["rows"]:
|
||||
group_of[r["path"]] = g
|
||||
dr = disc_root(r["path"])
|
||||
if dr:
|
||||
group_of[dr] = g
|
||||
by_path = {r["path"]: r for r in erows}
|
||||
|
||||
# -------------------------------------------------------------- bookkeeping
|
||||
claimed = {}
|
||||
existing = set(rows_map := {r["path"] for r in rows})
|
||||
|
||||
|
||||
at_target, booked_src = set(), set()
|
||||
|
||||
|
||||
def book(src, dst, kind):
|
||||
if src == dst:
|
||||
at_target.add(src)
|
||||
booked_src.add(src)
|
||||
return
|
||||
if os.environ.get("JF_TRACE") and (src in booked_src or dst.lower() in claimed):
|
||||
why = "rebook" if src in booked_src else "collision"
|
||||
problems.append(f"TRACE {why} kind={kind} src={rel(src)} dst={rel(dst)}")
|
||||
if src in booked_src: # one source may only ever move once
|
||||
problems.append(f"RE-BOOK {rel(src)} -> {rel(dst)} (already planned)")
|
||||
return
|
||||
if dst.lower() in claimed:
|
||||
problems.append(f"COLLISION {rel(dst)}\n from {rel(src)}\n and {rel(claimed[dst.lower()])}")
|
||||
return
|
||||
claimed[dst.lower()] = src
|
||||
booked_src.add(src)
|
||||
comp = dst.split("/")
|
||||
for i in range(1, len(comp)):
|
||||
mkdirs.add("/".join(comp[:i]))
|
||||
moves.append((src, dst, kind))
|
||||
|
||||
|
||||
def uniq_dir(d, stem, ext, hint=""):
|
||||
"""A destination that no other source already took."""
|
||||
cand = f"{d}/{stem}{ext}"
|
||||
if cand.lower() not in claimed:
|
||||
return cand
|
||||
n = 2
|
||||
while True:
|
||||
cand = f"{d}/{stem} {n}{ext}"
|
||||
if cand.lower() not in claimed:
|
||||
return cand
|
||||
n += 1
|
||||
|
||||
|
||||
def media_dir(p):
|
||||
dr = disc_root(p)
|
||||
return os.path.dirname(dr) if dr else os.path.dirname(p)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ films
|
||||
# film / disc moves: the largest copy carries the folder's own name, the rest
|
||||
# become Jellyfin versions; a disc tree moves as a whole into the movie folder
|
||||
|
||||
|
||||
def vext(p):
|
||||
return ext_by.get(p) or os.path.splitext(p)[1].lower()
|
||||
disc_children = collections.defaultdict(list)
|
||||
for q in rows:
|
||||
dr = disc_root(q["path"])
|
||||
if dr:
|
||||
disc_children[dr].append(q["path"])
|
||||
for g in groups:
|
||||
primary_taken = False
|
||||
disc_kinds = set()
|
||||
for r in sorted(g["rows"], key=lambda r: -r["bytes"]):
|
||||
dr = disc_root(r["path"])
|
||||
if dr:
|
||||
inner = os.path.basename(dr).upper()
|
||||
if inner in disc_kinds:
|
||||
problems.append(
|
||||
f"DISC-VERSION {g['base']}: extra disc tree {rel(dr)} cannot "
|
||||
f"share the folder — discs have no multiple versions")
|
||||
continue
|
||||
disc_kinds.add(inner)
|
||||
for src in disc_children[dr]:
|
||||
tail = os.path.relpath(src, dr)
|
||||
fold = disc_root(src, fold=False)
|
||||
if fold != dr and os.path.dirname(src) != dr:
|
||||
tail = os.path.basename(dr) + "/" + tail
|
||||
book(src, f"{g['dir']}/{tail}", "disc-file")
|
||||
continue
|
||||
label = "" if not primary_taken else f" - {version_label(r)}"
|
||||
dst = f"{g['dir']}/{g['base']}{label}{vext(r['path'])}"
|
||||
n = 2
|
||||
while dst.lower() in claimed:
|
||||
dst = f"{g['dir']}/{g['base']}{label} {n}{vext(r['path'])}"
|
||||
n += 1
|
||||
r["dst"] = dst
|
||||
primary_taken = True
|
||||
book(r["path"], dst, "film")
|
||||
g["primary"] = primary_taken
|
||||
|
||||
# ------------------------------------------------------------------ extras
|
||||
EXTRA_KIND = [("trailers", r"trailer|teaser|preview|promo|tv spot|advert"),
|
||||
("deleted scenes", r"deleted|alt scene|gag reel|blooper|outtake"),
|
||||
("interviews", r"interview|cast and crew|session piece|commentary"),
|
||||
("featurettes", r"featurette|behind the scenes|making of|documentary"
|
||||
r"|heritage|archival|video archive|music video"),
|
||||
("samples", r"sample"),
|
||||
("shorts", r"\bshort\b|short film"),
|
||||
("clips", r"\bclip\b|scene selection|montage|epilogue|isolated score"),
|
||||
("other", r"menu|logo|gallery|still|trivia|storyboard|screen test"
|
||||
r"|audition|test footage|watermark|workprint|poster"
|
||||
r"|picture in picture|singalong|karaoke|original score")]
|
||||
THEME_RE = re.compile(r"theme|opening song|ending song|soundtrack|score"
|
||||
r"|\bost\b|music|song", re.I)
|
||||
BACKDROP_RE = re.compile(r"backdrop|intro", re.I)
|
||||
|
||||
|
||||
def extras_kind(path):
|
||||
low = path.lower()
|
||||
if THEME_RE.search(low):
|
||||
return "theme-music"
|
||||
if BACKDROP_RE.search(low):
|
||||
return "backdrops"
|
||||
for name, rx in EXTRA_KIND:
|
||||
if re.search(rx, low):
|
||||
return name
|
||||
return "extras"
|
||||
|
||||
|
||||
def attachable_title(path):
|
||||
t, _y = eng.title_of(path)
|
||||
return eng.key_of(t) if t and not eng.is_watermark(path, size_by.get(path, 0)) else None
|
||||
|
||||
|
||||
for g in groups:
|
||||
g["tops"] = {parts_of(r["path"])[0] for r in g["rows"]}
|
||||
top_groups = collections.defaultdict(list)
|
||||
for g in groups:
|
||||
for t in g["tops"]:
|
||||
top_groups[t].append(g)
|
||||
group_by_key = collections.defaultdict(list)
|
||||
for g in groups:
|
||||
for r in g["rows"]:
|
||||
group_by_key[eng.key_of(r["title"])].append(g)
|
||||
group_by_dir = {}
|
||||
for g in groups:
|
||||
for r in g["rows"]:
|
||||
group_by_dir.setdefault(media_dir(r["path"]), g)
|
||||
dr = disc_root(r["path"])
|
||||
if dr:
|
||||
group_by_dir.setdefault(os.path.dirname(dr), g)
|
||||
|
||||
|
||||
def group_words(g):
|
||||
"""The words that identify a film group: its titles and the names of the
|
||||
folders its files live in."""
|
||||
if "words" not in g:
|
||||
w = set()
|
||||
if g.get("plex"):
|
||||
w |= words_of(g["plex"])
|
||||
for r in g["rows"]:
|
||||
w |= words_of(r["title"])
|
||||
w |= words_of(os.path.basename(media_dir(r["path"])))
|
||||
g["words"] = w
|
||||
return g["words"]
|
||||
|
||||
|
||||
def score_words(text, groups):
|
||||
"""Ranks film groups by how many of their words a path carries."""
|
||||
words = words_of(text)
|
||||
out = [(len(words & group_words(g)), g) for g in groups]
|
||||
out.sort(key=lambda x: -x[0])
|
||||
return out
|
||||
|
||||
|
||||
def group_for_orphan(path):
|
||||
"""Which movie folder an extra/sidecar belongs to. A collection folder holding
|
||||
several films must be resolved by WORDS, never by position: taking the first
|
||||
film of the folder attached every loose subtitle in a box set to whichever
|
||||
movie happened to sort first."""
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
key = attachable_title(path)
|
||||
if key and group_by_key.get(key):
|
||||
cands = group_by_key[key]
|
||||
top = parts_of(path)[0] if len(parts_of(path)) > 1 else ""
|
||||
same_top = [g for g in cands if any(parts_of(r["path"])[0] == top
|
||||
for r in g["rows"] if len(parts_of(r["path"])) > 1)]
|
||||
pool = same_top or cands
|
||||
return pool[0] if len(pool) == 1 else score_words(stem, pool)[0][1]
|
||||
gtop = parts_of(path)[0] if len(parts_of(path)) > 1 else ""
|
||||
pool = top_groups.get(gtop, [])
|
||||
if len(pool) > 1: # a loose collection folder (box set, bonus
|
||||
n, g = score_words(stem + " " + os.path.dirname(path), pool)[0]
|
||||
return g if n >= 2 else None # album) is resolved by words, not position
|
||||
if len(pool) == 1:
|
||||
return pool[0]
|
||||
d = os.path.dirname(path)
|
||||
while d and d != ROOT:
|
||||
if d in group_by_dir:
|
||||
return group_by_dir[d]
|
||||
d = os.path.dirname(d)
|
||||
return None
|
||||
|
||||
|
||||
tv_tops = {parts_of(p)[0] for p in tv_set}
|
||||
def film_folder_for(path):
|
||||
"""A movie folder for a lone feature that has no group: derived from its own
|
||||
title when a year proves it, else None (goes to the review pile)."""
|
||||
t, y = eng.title_of(path)
|
||||
if not t or y == "?":
|
||||
return None
|
||||
d = uniq_dir(ROOT, sanitize(f"{t} ({y})"), "", "")
|
||||
mkdirs.add(d)
|
||||
return d
|
||||
|
||||
|
||||
for p in sorted(extras):
|
||||
if disc_root(p):
|
||||
continue
|
||||
if len(parts_of(p)) > 1 and parts_of(p)[0] in tv_tops:
|
||||
continue # a special inside a TV set: staged with it
|
||||
g = group_for_orphan(p)
|
||||
ext = ext_by.get(p, os.path.splitext(p)[1].lower())
|
||||
stem = os.path.splitext(os.path.basename(p))[0]
|
||||
if g is None:
|
||||
solo = film_folder_for(p)
|
||||
if solo:
|
||||
dst = f"{solo}/{sanitize(stem)}{ext}"
|
||||
problems.append(f"REVIEW lone feature {rel(p)}: gets its own folder "
|
||||
f"{os.path.basename(solo)}")
|
||||
else:
|
||||
dst = f"{ROOT}/_unsorted bonus/{extras_kind(p)}/{sanitize(stem)}{ext}"
|
||||
problems.append(f"NO-FILM extra {rel(p)}")
|
||||
else:
|
||||
dst = f"{g['dir']}/{extras_kind(p)}/{sanitize(stem)}{ext}"
|
||||
book(p, uniq_dir(os.path.dirname(dst), os.path.basename(dst)[:-(len(ext) or 1)],
|
||||
ext, os.path.dirname(p)), "extra")
|
||||
|
||||
# Disc trees the engine never saw (a MakeMKV scratch folder, an AACS remnant):
|
||||
# they belong to the film of the folder they sit in, kept as a named subtree.
|
||||
for q in rows:
|
||||
pth = q["path"]
|
||||
dr = disc_root(pth)
|
||||
if not dr or pth in by_path or pth in booked_src:
|
||||
continue
|
||||
g = group_by_dir.get(dr) or (top_groups[parts_of(pth)[0]][0]
|
||||
if len(top_groups.get(parts_of(pth)[0], [])) == 1 else None)
|
||||
if g is None:
|
||||
continue
|
||||
inner = os.path.basename(dr)
|
||||
problems.append(f"REVIEW disc remnant {rel(dr)} moves inside {g['base']} "
|
||||
f"(a second master: compare before keeping both)")
|
||||
for src in disc_children[dr]:
|
||||
if src not in booked_src:
|
||||
book(src, f"{g['dir']}/{inner}/{os.path.relpath(src, dr)}", "disc-file")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- sidecars
|
||||
media_by_dir = collections.defaultdict(list)
|
||||
for r in erows:
|
||||
if r["path"] in marks:
|
||||
continue
|
||||
dst = r.get("dst")
|
||||
if dst:
|
||||
media_by_dir[media_dir(r["path"])].append(r)
|
||||
|
||||
|
||||
|
||||
def sidecar_target(path, e):
|
||||
d = os.path.dirname(path)
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
if d == ROOT:
|
||||
# A file loose in the library ROOT names no film. media_by_dir[ROOT] is the
|
||||
# set of root-level MOVIE files, and scoring a stray name against it
|
||||
# attached every root-level leftover to whichever root-level film was
|
||||
# largest — the plan sent ".DS_Store" into "Titanic (1997)/other/" that
|
||||
# way. A root-level stray is nobody's sidecar: it goes to the review pile.
|
||||
return None, None
|
||||
cands = media_by_dir.get(d, [])
|
||||
if not cands:
|
||||
nd = os.path.dirname(d) # a subs/ or Extras/ folder beside the media
|
||||
while nd and nd != ROOT and not media_by_dir.get(nd):
|
||||
nd = os.path.dirname(nd)
|
||||
if media_by_dir.get(nd):
|
||||
if nd != d: # a NAMED subfolder (Subs/, Extras/, the
|
||||
g = group_for_orphan(path) # box set's own root) carries evidence
|
||||
dirs = {os.path.dirname(r.get("dst", "")) for r in media_by_dir[nd]}
|
||||
if g is None or g["dir"] not in dirs:
|
||||
# ...but only a STRONG word match outranks the folder the file
|
||||
# sits in. A weak one is an accident: "3_Eng.srt" scored 1
|
||||
# against a junk group named "3" and escaped its own movie's
|
||||
# Subs/ folder, while "03 xXx 3 Return Of Xander Cage" scores 4
|
||||
# and genuinely belongs to the other film of the set.
|
||||
n = score_words(stem, [g])[0][0] if g else 0
|
||||
if n >= 2:
|
||||
return (g, None)
|
||||
cands = media_by_dir[nd]
|
||||
if not cands:
|
||||
g = group_for_orphan(path)
|
||||
return (g, None) if g else (None, None)
|
||||
if len(cands) > 1:
|
||||
w = [t for t in eng.norm(stem).split() if t not in NOISE]
|
||||
cands = sorted(cands, key=lambda r: (
|
||||
-len([t for t in eng.norm(os.path.basename(r["path"])).split() if t in w]),
|
||||
-r["bytes"]))
|
||||
return (None, cands[0])
|
||||
|
||||
|
||||
for q in rows:
|
||||
p, e = q["path"], q["ext"]
|
||||
if p in by_path or p in extras or p in marks or is_qb(p) or disc_root(p):
|
||||
continue
|
||||
if os.path.basename(p).startswith("._"):
|
||||
# macOS AppleDouble resource forks: a few hundred bytes carrying a name
|
||||
# COPIED FROM ANOTHER FILM. Word-matching them to "their" film scattered
|
||||
# dead files through four movie folders (and they are video extensions in
|
||||
# the AppleDouble case, so they also skipped every other filter). They
|
||||
# claim nothing: one pile, and they stay in it — nothing is deleted here.
|
||||
book(p, uniq_dir(f"{ROOT}/_macos sidecars", sanitize(os.path.basename(p))[:120],
|
||||
"", os.path.dirname(p)), "junk")
|
||||
problems.append(f"JUNK AppleDouble sidecar {rel(p)} (macOS resource fork)")
|
||||
continue
|
||||
if e == ".iso": # a DVD image: Jellyfin plays it as a movie file
|
||||
g = group_for_orphan(p)
|
||||
stem = os.path.splitext(os.path.basename(p))[0]
|
||||
if g is not None:
|
||||
book(p, uniq_dir(g["dir"], g["base"], e, stem) if g.get("primary") is not None
|
||||
else f"{g['dir']}/{g['base']}{e}", "iso")
|
||||
else:
|
||||
solo = film_folder_for(p)
|
||||
if solo:
|
||||
book(p, f"{solo}/{sanitize(stem)}.iso", "iso")
|
||||
problems.append(f"REVIEW disc image {rel(p)}: DVD rip, alone in its "
|
||||
f"folder -> {os.path.basename(solo)}")
|
||||
else:
|
||||
book(p, f"{ROOT}/_unsorted files/{sanitize(os.path.basename(p))}", "orphan")
|
||||
problems.append(f"NO-FILM disc image {rel(p)}")
|
||||
continue
|
||||
if e in VIDEO_EXT:
|
||||
# A video the engine never probed (unreadable header) and not an AppleDouble
|
||||
# remnant: the plan does not guess at it, so it stays where it is.
|
||||
continue
|
||||
stem = os.path.splitext(os.path.basename(p))[0]
|
||||
g, media = sidecar_target(p, e)
|
||||
if media is not None:
|
||||
base = os.path.splitext(os.path.basename(media["dst"]))[0]
|
||||
d = os.path.dirname(media["dst"])
|
||||
elif g is not None:
|
||||
base, d = g["base"], g["dir"]
|
||||
else:
|
||||
# Nothing names this file's film. NEVER scatter it: an archive set or an
|
||||
# album tree must move as the unit it is, so the whole loose folder goes
|
||||
# to the review pile and a film folder's own leftovers stay with it.
|
||||
ps = parts_of(p)
|
||||
if len(ps) < 2:
|
||||
dst = f"{ROOT}/_unsorted files/{sanitize(stem)}{e}"
|
||||
elif len(top_groups.get(ps[0], [])):
|
||||
dst = f"{top_groups[ps[0]][0]['dir']}/other/{'/'.join(sanitize(c) for c in ps[1:])}"
|
||||
else:
|
||||
dst = f"{ROOT}/_unsorted files/{'/'.join(sanitize(c) for c in ps)}"
|
||||
book(dst=dst, src=p, kind="orphan")
|
||||
problems.append(f"NO-OWNER sidecar {rel(p)}")
|
||||
continue
|
||||
if e in IMG_EXT:
|
||||
low = eng.norm(stem)
|
||||
word = next((w for w in IMG_PRIMARY if re.search(rf"\b{w}\b", low)), None)
|
||||
kind = "poster" if word else next(
|
||||
(IMG_OTHER[w] for w in IMG_OTHER if re.search(rf"\b{w}\b", low)), None)
|
||||
if re.search(r"\btheme\b", low):
|
||||
dst = f"{d}/theme{e}"
|
||||
else:
|
||||
dst = f"{d}/{base}-{kind or 'art'}{e}" if kind else f"{d}/{sanitize(stem)}{e}"
|
||||
book(p, uniq_dir(os.path.dirname(dst), os.path.basename(dst)[:-(len(e) or 1)],
|
||||
e, os.path.dirname(p)), "image")
|
||||
elif e in NFO_EXT:
|
||||
book(p, uniq_dir(d, base, ".nfo", os.path.dirname(p)), "nfo")
|
||||
elif e in SUB_EXT | AUDIO_EXT:
|
||||
toks = [t for t in re.split(r"[._\-\s]+", stem) if t]
|
||||
langs = [t.lower() for t in toks if t.lower() in LANGS]
|
||||
flags = [t.lower() for t in toks if t.lower() in FLAGS]
|
||||
keep = [sanitize(t) for t in toks
|
||||
if t.lower() not in langs + flags + list(NOISE)
|
||||
and not re.fullmatch(r"\d{3,4}p?|cd\d+|\d{4}|\d+ch|\d+.\d", t.lower())]
|
||||
bits = [base] + ([sanitize(" ".join(keep[:4]))] if keep else []) + langs + flags
|
||||
if e in SUB_EXT:
|
||||
# subtitles: <movie>/srt[/language]
|
||||
d = f"{d}/srt"
|
||||
pd = os.path.basename(os.path.dirname(p))
|
||||
if SUB_KEEP_RE.match(pd) and not SUB_DIR_OK.match(pd):
|
||||
d = f"{d}/{sanitize(pd)}" # a real language folder survives
|
||||
elif e in AUDIO_EXT and AUDIO_DIR_RE.search(os.path.dirname(p)):
|
||||
d = f"{d}/theme-music" # bonus audio: one canonical folder
|
||||
pd = os.path.basename(os.path.dirname(p))
|
||||
if not MUSIC_TOP_RE.search(pd): # an album folder keeps its name
|
||||
d = f"{d}/{sanitize(pd)}"
|
||||
else:
|
||||
pd = os.path.basename(os.path.dirname(p))
|
||||
if not AUDIO_DIR_OK.match(pd):
|
||||
d = f"{d}/{sanitize(pd)}"
|
||||
stem2 = ".".join(bits) or sanitize(os.path.basename(p))
|
||||
book(p, uniq_dir(d, stem2, e, os.path.basename(p)), "track")
|
||||
else:
|
||||
book(p, uniq_dir(f"{d}/other", sanitize(os.path.basename(p))[:120], e,
|
||||
os.path.dirname(p)), "misc")
|
||||
|
||||
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.")
|
||||
|
||||
# --------------------------------------------------------------------- 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 "")
|
||||
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
|
||||
and parts_of(q)[0] in tops],
|
||||
key=lambda q: q)
|
||||
for src in members:
|
||||
rel_parts = rel(src).split("/")
|
||||
tail = "/".join(sanitize(c) for c in rel_parts[:-1] + [rel_parts[-1]])
|
||||
book(src, f"{ROOT}/{base}/{tail}", "tv")
|
||||
for p in sorted(marks):
|
||||
stem, ext = os.path.splitext(os.path.basename(p))
|
||||
book(p, uniq_dir(f"{ROOT}/_release-group watermarks", sanitize(stem)[:120], ext,
|
||||
os.path.dirname(p)), "watermark")
|
||||
for q in rows:
|
||||
if is_qb(q["path"]):
|
||||
problems.append(f"SKIPPED qBittorrent partial {rel(q['path'])}")
|
||||
book(q["path"], q["path"], "qb-partial") # stays: resume data
|
||||
|
||||
# --------------------------------------------------------------- debug hook
|
||||
if os.environ.get("JF_ISO"):
|
||||
x = ROOT + "/High Anxiety (Mel Brooks) [1977] nordic subs DVDrip/High Anxiety [1977].iso"
|
||||
print("attachable", attachable_title(x), "| dir", os.path.dirname(x) in group_by_dir,
|
||||
"| g", group_for_orphan(x) and group_for_orphan(x)["base"])
|
||||
raise SystemExit
|
||||
if os.environ.get("JF_DEBUG"):
|
||||
import json
|
||||
unp = sorted({r["path"] for r in rows} - {m[0] for m in moves} - at_target)
|
||||
print("at_target", len(at_target))
|
||||
print("kinds-unplanned", collections.Counter(os.path.splitext(u)[1] for u in unp).most_common(8))
|
||||
for up in unp[:20]:
|
||||
d = os.path.dirname(up)
|
||||
print("UNP", up[:90], "| mediadir?", d in media_by_dir, "| by_path", up in by_path,
|
||||
"| extras", up in extras, "| discroot", disc_root(up), "| ext", os.path.splitext(up)[1])
|
||||
|
||||
# -------------------------------------------------------------- validation
|
||||
srcs = [m[0] for m in moves]
|
||||
dup_src = {s for s, n in collections.Counter(srcs).items() if n > 1}
|
||||
for s in dup_src:
|
||||
problems.append(f"DOUBLE-CLAIM source {rel(s)}")
|
||||
all_src = set(srcs)
|
||||
untouched = {r["path"] for r in rows} - all_src
|
||||
bad_names, into_alive, lost = [], [], []
|
||||
for src, dst, kind in moves:
|
||||
for comp in dst[len(ROOT) + 1:].split("/"):
|
||||
if (BAD_CHARS.search(comp) or comp != comp.strip(". ") or not comp
|
||||
or len(comp) > 200):
|
||||
bad_names.append(f"{kind}: {comp!r} in {rel(dst)}")
|
||||
if dst in untouched: # something staying put is there
|
||||
into_alive.append(f"{rel(src)} -> {rel(dst)} (occupied by a file that stays)")
|
||||
if dst.lower() in {s.lower() for s in untouched}:
|
||||
into_alive.append(f"{rel(src)} -> {rel(dst)} (case-collides with a survivor)")
|
||||
if dst.startswith(src + "/") and src in all_src:
|
||||
lost.append(f"{rel(src)} moves into its own subtree")
|
||||
# every accounted-for source must exist exactly once in the plan
|
||||
covered = {r["path"] for r in rows}
|
||||
not_planned = sorted(covered - all_src - at_target)
|
||||
|
||||
with open(PLAN, "w") as out:
|
||||
out.write("action\tsrc\tdst\tkind\n")
|
||||
for d in sorted(mkdirs, key=lambda q: (q.count("/"), q)):
|
||||
if d.startswith(ROOT + "/") and d != ROOT:
|
||||
out.write(f"mkdir\t\t{d}\tdir\n")
|
||||
for p in sorted(booked_src - {m[0] for m in moves}):
|
||||
out.write(f"keep\t{p}\t{p}\n")
|
||||
for src, dst, kind in sorted(moves, key=lambda m: m[0]):
|
||||
out.write(f"move\t{src}\t{dst}\t{kind}\n")
|
||||
|
||||
kinds = collections.Counter(k for _s, _d, k in moves)
|
||||
films_n = sum(1 for _s, _d, k in moves if k == "film")
|
||||
folders = len({os.path.dirname(d) for _s, d, k in moves if k in ("film", "disc-file")})
|
||||
with open(EXCEPTS, "w") as out:
|
||||
out.write(f"groups={len(groups)} moves={len(moves)} folders={folders}\n")
|
||||
out.write(f"kinds={dict(kinds)}\n")
|
||||
out.write(f"at-target-already={len(at_target)} unplanned={len(not_planned)}\n")
|
||||
out.write("\n=== WHAT THE EXECUTOR MUST ALSO WRITE ===\n")
|
||||
out.write("\n".join(sorted(set(readmes))) + "\n")
|
||||
out.write("\n=== PROBLEMS ===\n")
|
||||
out.write("\n".join(sorted(set(problems))) + "\n")
|
||||
out.write("\n=== BAD NAMES ===\n" + "\n".join(sorted(set(bad_names))) + "\n")
|
||||
out.write("\n=== TARGETS OCCUPIED BY SURVIVORS ===\n"
|
||||
+ "\n".join(sorted(set(into_alive))) + "\n")
|
||||
out.write("\n=== LEFTOVER SOURCES (first 60) ===\n"
|
||||
+ "\n".join(rel(p) for p in not_planned[:60]) + "\n")
|
||||
print(f"groups={len(groups)} folders={folders} moves={len(moves)} "
|
||||
f"mkdirs={len(mkdirs)} film-files={films_n}")
|
||||
print(f"kinds={dict(kinds)}")
|
||||
print(f"problems={len(set(problems))} bad-names={len(set(bad_names))} "
|
||||
f"occupied={len(set(into_alive))} double-claim={len(dup_src)}")
|
||||
print(f"left-unmoved={len(not_planned)}")
|
||||
print(f"tv-keys={len(tv_keys)} extras={len(extras)} marks={len(marks)} "
|
||||
f"disc-trees={len(disc_children)}")
|
||||
Reference in new issue
Block a user