feat(media): measure a series per episode, and let the tag outrank a borrowed id
media-tvdupes.py censuses a TV library by episode key and never by folder. 10 of the 25 shows that looked duplicated in the 2026-11-02 census held complementary halves (Game of Thrones Complete carries no S01 or S08, Modern Family S05 is the only copy of season 5), so a folder-level verdict would have staged some 340 GB of unique episodes to recover 54 GiB of true copies. Show identity comes from the folder's [tvdbid-N] tag, from the tvshow.nfo the metadata view exposes, or from an operator --ids map. A derived id that contradicts a tag is dropped and reported, never unioned: Jellyfin's provider table carries a 2026 special's id on the folder tagged [tvdbid-73838], and unioning the two makes one show of a 2000 series and a 2026 special whose bigger files then dominate the original's pilot. Files named by an id key only when the server's own episode table (the --witness) names them, so a bare number is never mistaken for an episode. Seam: tests/media_tvdupes.bats — 12 assertions on which files land in the plan and which the tool refuses to touch, on a synthetic library.
This commit is contained in:
1 parent
a25f139281
commit
9f9043c037
5 files changed
+790
-5
No files matched your search
Executable
+473
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env python3
|
||||
"""media-tvdupes.py — find duplicate TV episodes and plan their staging, one
|
||||
EPISODE at a time.
|
||||
|
||||
A TV library grows the same show into several folders: a tagged season box, an
|
||||
untagged packager drop, a `Complete` folder that is not complete. The folders
|
||||
look like copies of each other and are not, so the unit of comparison is the
|
||||
episode and never the folder. A file is droppable only when EVERY episode it
|
||||
carries is also held, somewhere else in the same show, by a file at least as
|
||||
large — Frank's rule: the same episode in two places keeps the larger one. A
|
||||
file that holds one episode nothing else has stays, whatever else it carries.
|
||||
|
||||
It writes a plan and moves nothing; `scripts/media-apply.py` is the only thing
|
||||
that renames. The plan stages into a dated hold, so the pass is undoable from
|
||||
the journal and frees no space until the hold is purged.
|
||||
|
||||
python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
||||
--hold /mnt/entropy/.AGENTTRASH/20261102-tv-duplicates \
|
||||
--out /mnt/entropy/tv-dupes-plan.tsv
|
||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/tv-dupes-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/tv-dupes.jsonl \
|
||||
--metadata-view /var/lib/media-meta/view --apply
|
||||
python3 scripts/media-apply.py verify --plan /mnt/entropy/tv-dupes-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/tv-dupes.jsonl
|
||||
python3 scripts/media-apply.py sweep --root /mnt/entropy \
|
||||
--journal /mnt/entropy/tv-dupes.jsonl --apply # empty folders only
|
||||
scripts/media-view.sh reap # ghost sidecars above
|
||||
|
||||
Then both servers have to be told: the plan moves files on the raw array, and no
|
||||
watcher crosses the metadata view — [[Media Relayout]] owns the refresh order.
|
||||
|
||||
Guards, and why each one is here:
|
||||
|
||||
* **Identity before geometry.** Two folders holding the same words are not the
|
||||
same show: the 2026-11-02 census found `Avatar: The Last Airbender` (1999,
|
||||
tvdb 74852) beside the 2024 remake (385925), `Star Trek: Voyager` beside
|
||||
`Star Trek: The Animated Series`, and a German thriller Jellyfin had titled
|
||||
`Millennium (2022) [tvdbid-472933]` beside `Millennium (1996)`. A folder's
|
||||
identity is read from three places, in this order: its `[tvdbid-N]` tag, the
|
||||
`tvshow.nfo` Jellyfin wrote — which lives in the metadata view, not on the
|
||||
array, and is what `--meta` reads — and a `folder<TAB>id` map passed with
|
||||
`--ids`. The tag outranks the derived layers: an id that contradicts a folder's
|
||||
own tag is reported and ignored, never unioned — measured 2026-11-02, Jellyfin's
|
||||
provider table carried a 2026 special's id on a folder tagged `[tvdbid-73838]`,
|
||||
and unioning them makes one show of the 2000 series and the special, whose bigger
|
||||
files then dominate the original's pilot. Two folders are one show when they share
|
||||
an id, or when neither has one and name and year both agree. Two disjoint id sets
|
||||
is an absolute stop: the pair is reported as a conflict and nothing under either is
|
||||
staged. Same name with nothing to decide on is reported unresolved — the fix is the
|
||||
tag, not a guess.
|
||||
* **A folder-level verdict destroys content.** 10 of the 25 shows that looked
|
||||
duplicated held COMPLEMENTARY halves: `Game of Thrones Complete` carries no
|
||||
S01 and no S08, `Modern Family S05` is the only copy of season 5, `Loiter
|
||||
Squad Uncensored` is the only copy of season 1. Judged per folder, 20 shows
|
||||
and some 340 GB of unique episodes would have been staged; judged per episode
|
||||
the true figure was a sixth of that.
|
||||
* **A bare number is never an episode.** `256491.mp4` may be an episode id, a
|
||||
poster, or a season, so a name that states no `SxxEyy` / `1x04` marker is
|
||||
unkeyed: never staged, and counted in the report. A media server's own episode
|
||||
table does know those files — pass it with `--witness` and its rows key
|
||||
whatever the server has matched.
|
||||
* **The audit re-reads the disk.** Sizes and the survivor's existence are
|
||||
checked again when the plan is written, because the census is a snapshot: a
|
||||
file that grew, vanished, or landed in the hold in between must fail the plan,
|
||||
not surprise the executor.
|
||||
* **A sidecar that shares an episode's stem follows it.** Subtitles and `.nfo`
|
||||
left at the old coordinates are ghost bait for `media-view.sh reap`.
|
||||
* A multi-episode file (`S01E01-02`) is dominated only when every episode in its
|
||||
range is held elsewhere — and it competes for the survivor seat on each of
|
||||
them, because the byte law compares files. A pack that states its range in
|
||||
the name is a name both media servers parse the same way, so the pack winning
|
||||
against two single-episode rips is the law applied, not a misread.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
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)
|
||||
|
||||
# The executor's definition of a media file, plus the two the title engine omits:
|
||||
# an audit that disagrees with media-apply.py is a plan that dies at its
|
||||
# preflight instead of at the census.
|
||||
VIDEO = tuple(sorted(set(eng.VIDEO_SUFFIX) | {".ts", ".wmv"}))
|
||||
TAG_RE = re.compile(r"\[\s*tvdbid[\s._-]*(\d+)\s*\]", re.I)
|
||||
YEAR_RE = re.compile(r"[(\[]\s*((?:19|20)\d\d)\s*[)\]]")
|
||||
SEASON_EP = re.compile(r"(?i)\bs(?:eason)?[\s._-]*(\d{1,2})[\s._-]*e(?:p(?:isode)?)?"
|
||||
r"[\s._-]*(\d{1,3})(?:[\s._-]*(?:-|to|\+)[\s._-]*e?"
|
||||
r"[\s._-]*(\d{1,3}))?")
|
||||
NXN = re.compile(r"(?i)(?<!\d)(\d{1,2})x(\d{1,3})(?!\d)")
|
||||
|
||||
|
||||
def parse_args():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--library", default="/mnt/entropy/TVShows",
|
||||
help="the TV library to census (never written)")
|
||||
ap.add_argument("--hold", required=True,
|
||||
help="dated trash the plan stages into, e.g. "
|
||||
"/mnt/entropy/.AGENTTRASH/20261102-tv-duplicates")
|
||||
ap.add_argument("--out", default=None,
|
||||
help="plan TSV for scripts/media-apply.py (default: "
|
||||
"tv-dupes-plan.tsv beside --library)")
|
||||
ap.add_argument("--witness", default=None,
|
||||
help="TSV of path<TAB>season<TAB>episode out of a media "
|
||||
"server's episode table; keys files whose names carry"
|
||||
" no marker")
|
||||
ap.add_argument("--meta", default=None,
|
||||
help="the merged metadata view (on the host "
|
||||
"/var/lib/media-meta/view): tvshow.nfo lives in the"
|
||||
" upper layer, so this is where an untagged folder's"
|
||||
" tvdb id is readable")
|
||||
ap.add_argument("--ids", default=None,
|
||||
help="TSV of folder<TAB>tvdbid, one line per folder, for"
|
||||
" identity a lookup settled and the folder name does"
|
||||
" not state")
|
||||
ap.add_argument("--json", default=None,
|
||||
help="also dump the analysis as JSON")
|
||||
return ap.parse_args()
|
||||
|
||||
|
||||
ARGS = parse_args()
|
||||
|
||||
|
||||
def episode_keys(name):
|
||||
"""Every (season, episode) an episode name states.
|
||||
|
||||
`S01E03`, `Season 1 E3`, `1x04`, and a range `S01E01-02`, which expands so a
|
||||
two-in-one file is only droppable when both halves are held elsewhere. A
|
||||
bare number states nothing and yields nothing."""
|
||||
keys = set()
|
||||
for m in SEASON_EP.finditer(name):
|
||||
s, first = int(m.group(1)), int(m.group(2))
|
||||
keys.add((s, first))
|
||||
if m.group(3):
|
||||
keys.update((s, e) for e in range(first + 1, int(m.group(3)) + 1))
|
||||
keys.update((int(m.group(1)), int(m.group(2))) for m in NXN.finditer(name))
|
||||
return keys
|
||||
|
||||
|
||||
def folder_identity(folder):
|
||||
"""(fold_sep name, year, tvdb ids) for a top-level show folder.
|
||||
|
||||
Tag and year come in both orders — `Name (2008) [tvdbid-84859]` and `Name
|
||||
[tvdbid-84859] (2008)` are both in the library — so neither is assumed to
|
||||
sit at the end, and both are cut out of the name folders compare on."""
|
||||
ids = frozenset(int(x) for x in TAG_RE.findall(folder))
|
||||
year = None
|
||||
for _ in range(3):
|
||||
y = YEAR_RE.search(folder)
|
||||
if not y:
|
||||
break
|
||||
year = year or y.group(1)
|
||||
folder = TAG_RE.sub(" ", f"{folder[:y.start()]} {folder[y.end():]}")
|
||||
return eng.fold_sep(TAG_RE.sub(" ", folder)), year, ids
|
||||
|
||||
|
||||
def nfo_id(path):
|
||||
"""The tvdb id a `tvshow.nfo` states, or None.
|
||||
|
||||
Three shapes are in the library: Jellyfin's `<uniqueid type="tvdb">`, Kodi's
|
||||
`<tvdbid>`, and a packager's `<id>` + `<id_type>` pair; some carry the id
|
||||
only in the title line as `[tvdbid-N]`."""
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
text = fh.read(16384)
|
||||
except OSError:
|
||||
return None
|
||||
for rx in (r"<tvdbid\s*>\s*(\d+)\s*<",
|
||||
r"<uniqueid[^>]*type\s*=\s*[\"']?tvdb[^>]*>\s*(\d+)\s*<",
|
||||
r"<id\s*>\s*(\d+)\s*</id>\s*<id_type>\s*(?:tvdb|thedotdb)",
|
||||
r"\[\s*tvdbid[\s._-]*(\d+)\s*\]"):
|
||||
m = re.search(rx, text, re.I)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def collect_ids(library, meta, ids_file):
|
||||
"""top-level folder -> {tvdb ids} from the nfo layer and an operator map.
|
||||
|
||||
`meta` is the merged metadata view: Jellyfin's sidecars live in the upper
|
||||
layer, so a folder's `tvshow.nfo` is not on the raw array. Reading the id
|
||||
from there is what makes an untagged packager folder identifiable at all —
|
||||
without it the pair stays unresolved and nothing under it is staged."""
|
||||
out = collections.defaultdict(set)
|
||||
if meta:
|
||||
try:
|
||||
tops = sorted(os.listdir(library))
|
||||
except OSError:
|
||||
tops = []
|
||||
for folder in tops:
|
||||
i = nfo_id(os.path.join(meta, folder, "tvshow.nfo"))
|
||||
if i:
|
||||
out[folder].add(i)
|
||||
if ids_file:
|
||||
with open(ids_file, encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
cols = line.rstrip("\n").split("\t")
|
||||
if len(cols) < 2 or not cols[1].strip().isdigit():
|
||||
continue
|
||||
out[cols[0]].add(int(cols[1].strip()))
|
||||
return out
|
||||
|
||||
|
||||
def witness_rows(path):
|
||||
"""absolute path -> {(season, episode)} from a media server's episode table."""
|
||||
out = collections.defaultdict(set)
|
||||
if not path:
|
||||
return out
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
cols = line.rstrip("\n").split("\t")
|
||||
if len(cols) < 3 or not cols[0].startswith("/"):
|
||||
continue
|
||||
try:
|
||||
out[os.path.abspath(cols[0])].add((int(cols[1]), int(cols[2])))
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def sidecars(path):
|
||||
"""The files beside PATH carrying its exact stem — subtitles, nfo, artwork."""
|
||||
folder, stem = os.path.split(path)
|
||||
stem = os.path.splitext(stem)[0]
|
||||
out = []
|
||||
try:
|
||||
entries = sorted(os.listdir(folder))
|
||||
except OSError:
|
||||
return out
|
||||
for e in entries:
|
||||
p = os.path.join(folder, e)
|
||||
if p == path or os.path.islink(p) or not os.path.isfile(p):
|
||||
continue
|
||||
if os.path.splitext(e)[0] == stem:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def scan(library, hold, witness):
|
||||
"""Every video file under LIBRARY: relative path, size, episode keys.
|
||||
|
||||
A file keys from the witness when it has a row (a server matched it), and
|
||||
from its own name otherwise; with neither it is unkeyed."""
|
||||
files = []
|
||||
for dirpath, dirnames, filenames in os.walk(library):
|
||||
dirnames[:] = sorted(d for d in dirnames
|
||||
if not d.startswith(".") and d != "@eaDir"
|
||||
and os.path.abspath(os.path.join(dirpath, d)) != hold)
|
||||
for f in sorted(filenames):
|
||||
p = os.path.join(dirpath, f)
|
||||
if os.path.islink(p) or not f.lower().endswith(VIDEO):
|
||||
continue
|
||||
try:
|
||||
size = os.path.getsize(p)
|
||||
except OSError:
|
||||
continue
|
||||
files.append({"path": os.path.abspath(p),
|
||||
"rel": os.path.relpath(os.path.abspath(p), library),
|
||||
"size": size,
|
||||
"keys": set(witness.get(os.path.abspath(p))
|
||||
or episode_keys(os.path.splitext(f)[0]))})
|
||||
return files
|
||||
|
||||
|
||||
def show_groups(folders, extra_ids):
|
||||
"""(show groups, conflicts, unresolved, disagreements) over top-level folders.
|
||||
|
||||
A folder's id is its `[tvdbid-N]` tag; the nfo layer and `--ids` fill a folder
|
||||
only when it carries no tag. The rule, in order of trust: an id in common makes
|
||||
one show; two disjoint id sets are two shows, reported as a conflict and never
|
||||
merged; no id on either side merges only on name AND year (the 1999 miniseries
|
||||
and the 2015 series are not one show); one side ided and the other bare is
|
||||
unresolved, because the fix is the tag.
|
||||
|
||||
The derived layers never overrule a tag. Measured 2026-11-02: Jellyfin's
|
||||
provider table carried a 2026 special's tvdb id on the folder tagged
|
||||
`[tvdbid-73838]`, and unioning the two made one show of the 2000 series and the
|
||||
special — whose bigger files then dominate the original's pilot. A contradiction
|
||||
is reported instead, because the tag is the operator's word and the provider
|
||||
table is a guess that can be wrong.
|
||||
"""
|
||||
names = {f: folder_identity(f) for f in folders}
|
||||
tags = {f: set(names[f][2]) for f in folders}
|
||||
derived = {f: set(extra_ids.get(f, ())) for f in folders}
|
||||
disagree = {f: (sorted(tags[f]), sorted(derived[f] - tags[f]))
|
||||
for f in folders if tags[f] and derived[f] - tags[f]}
|
||||
ids = {f: (tags[f] or derived[f]) for f in folders}
|
||||
parent = {f: f for f in folders}
|
||||
|
||||
def find(f):
|
||||
while parent[f] != f:
|
||||
parent[f] = parent[parent[f]]
|
||||
f = parent[f]
|
||||
return f
|
||||
|
||||
conflicts, unresolved = [], []
|
||||
order = sorted(folders)
|
||||
for i, a in enumerate(order):
|
||||
for b in order[i + 1:]:
|
||||
na, ya, _ia = names[a]
|
||||
nb, yb, _ib = names[b]
|
||||
if na != nb:
|
||||
continue
|
||||
ia, ib = ids[a], ids[b]
|
||||
if ia & ib:
|
||||
parent[find(b)] = find(a)
|
||||
elif ia and ib:
|
||||
conflicts.append((a, b, f"tvdb {sorted(ia)} vs {sorted(ib)}"))
|
||||
elif not ia and not ib and ya and ya == yb:
|
||||
parent[find(b)] = find(a)
|
||||
elif not ia and not ib:
|
||||
conflicts.append((a, b,
|
||||
f"same name, years {ya or '?'} vs {yb or '?'}"))
|
||||
else:
|
||||
unresolved.append((a, b, "one folder carries a tvdb id,"
|
||||
" the other none"))
|
||||
groups = collections.defaultdict(list)
|
||||
for f in order:
|
||||
groups[find(f)].append(f)
|
||||
return sorted(groups.values()), conflicts, unresolved, disagree
|
||||
|
||||
|
||||
def rank(rec):
|
||||
"""Survivor order: biggest, then shortest path, then lexicographic.
|
||||
|
||||
A strict total order per key means a key's winner is never itself dominated,
|
||||
so no chain of staging can cycle and exactly one copy of a byte-identical
|
||||
pair is staged."""
|
||||
return (-rec["size"], len(rec["path"]), rec["path"])
|
||||
|
||||
|
||||
def main():
|
||||
library = os.path.abspath(ARGS.library)
|
||||
hold = os.path.abspath(ARGS.hold)
|
||||
out = ARGS.out or os.path.join(os.path.dirname(library), "tv-dupes-plan.tsv")
|
||||
if not os.path.isdir(library):
|
||||
sys.exit(f"refused: {library} is not a directory")
|
||||
if hold == library or hold.startswith(library + os.sep):
|
||||
sys.exit(f"refused: --hold {hold} sits inside the library; a hold the"
|
||||
" census walks is a plan that eats its own output")
|
||||
|
||||
files = scan(library, hold, witness_rows(ARGS.witness))
|
||||
folders = sorted({r["rel"].split(os.sep)[0] for r in files})
|
||||
ids = collect_ids(library, ARGS.meta, ARGS.ids)
|
||||
groups, conflicts, unresolved, disagree = show_groups(folders, ids)
|
||||
|
||||
by_show = collections.defaultdict(list)
|
||||
for members in groups:
|
||||
for r in files:
|
||||
if r["rel"].split(os.sep)[0] in members:
|
||||
by_show[members[0]].append(r)
|
||||
|
||||
rows, audits, report, shows_json = [], [], [], []
|
||||
for show in sorted(by_show):
|
||||
recs = by_show[show]
|
||||
holders = collections.defaultdict(list)
|
||||
for r in recs:
|
||||
for k in r["keys"]:
|
||||
holders[k].append(r)
|
||||
winner = {k: min(v, key=rank) for k, v in holders.items()}
|
||||
dominated = [r for r in recs if r["keys"]
|
||||
and all(winner[k] is not r for k in r["keys"])]
|
||||
if not dominated:
|
||||
continue
|
||||
dup_keys = sorted({k for r in dominated for k in r["keys"]})
|
||||
sole = sum(1 for v in holders.values() if len(v) == 1)
|
||||
staged_folders = {os.path.dirname(r["rel"]) for r in dominated}
|
||||
for r in dominated:
|
||||
for k in r["keys"]:
|
||||
t = winner[k]
|
||||
try:
|
||||
now_src = os.path.getsize(r["path"])
|
||||
now_twin = os.path.getsize(t["path"])
|
||||
except OSError:
|
||||
audits.append(f"gone since the census: {r['rel']}"
|
||||
f" or its survivor {t['rel']}")
|
||||
break
|
||||
if now_twin < now_src:
|
||||
audits.append(f"survivor smaller on disk: {r['rel']}"
|
||||
f" {now_src} > {t['rel']} {now_twin}")
|
||||
for r in dominated:
|
||||
dst = os.path.join(hold, r["rel"])
|
||||
rows.append((r["path"], dst))
|
||||
for side in sidecars(r["path"]):
|
||||
rows.append((side, os.path.join(hold, os.path.relpath(side, library))))
|
||||
shows_json.append({"show": show, "files": len(dominated),
|
||||
"bytes": sum(r["size"] for r in dominated),
|
||||
"episodes": len(dup_keys),
|
||||
"sole-episode files left": sole,
|
||||
"folders": sorted(staged_folders)})
|
||||
n_ep, n_folder = len(dup_keys), len(staged_folders)
|
||||
report.append(
|
||||
f"{show}: stage {len(dominated)} files"
|
||||
f" ({sum(r['size'] for r in dominated) / 2**30:.1f} GiB) — {n_ep}"
|
||||
f" episode{n_ep != 1 and 's' or ''} held twice or more; {sole}"
|
||||
f" episode{sole != 1 and 's' or ''} exist in one copy only, so"
|
||||
f" {n_folder} folder{n_folder != 1 and 's' or ''} keep their files")
|
||||
|
||||
rows = sorted(set(rows))
|
||||
dests = set()
|
||||
for src, dst in rows:
|
||||
if dst in dests:
|
||||
audits.append(f"destination twice: {dst}")
|
||||
dests.add(dst)
|
||||
if os.path.exists(dst):
|
||||
audits.append(f"hold target occupied: {dst}")
|
||||
if not os.path.isfile(src):
|
||||
audits.append(f"source missing: {src}")
|
||||
|
||||
unkeyed = collections.Counter(os.path.dirname(r["rel"]) for r in files
|
||||
if not r["keys"])
|
||||
if ARGS.json:
|
||||
with open(ARGS.json, "w") as fh:
|
||||
json.dump({"shows": shows_json,
|
||||
"conflicts": [list(c) for c in conflicts],
|
||||
"unresolved": [list(u) for u in unresolved],
|
||||
"disagreement": {f: [t, d] for f, (t, d) in disagree.items()},
|
||||
"unkeyed": dict(unkeyed)}, fh, indent=1)
|
||||
|
||||
for line in report:
|
||||
print(line)
|
||||
for a, b, why in conflicts:
|
||||
print(f"CONFLICT (never merged): {a} | {b} — {why}")
|
||||
for a, b, why in unresolved:
|
||||
print(f"UNRESOLVED (tag it, then re-run): {a} | {b} — {why}")
|
||||
for f, (tagged, extra) in sorted(disagree.items()):
|
||||
print(f"DISAGREE (the tag wins): {f} — tag tvdb {tagged},"
|
||||
f" derived tvdb {extra}")
|
||||
if unkeyed:
|
||||
print(f"unkeyed, never staged: {sum(unkeyed.values())} files in"
|
||||
f" {len(unkeyed)} folders name no SxxEyy and have no --witness row;"
|
||||
" most: " + ", ".join(f"{d} ({n})" for d, n in unkeyed.most_common(5)))
|
||||
|
||||
# The gate runs before the plan is written: a plan file that exists is a plan
|
||||
# somebody will point the executor at, so a failed audit leaves no artifact.
|
||||
if audits:
|
||||
print(f"AUDIT FAILED ({len(audits)})", file=sys.stderr)
|
||||
for a in audits[:10]:
|
||||
print(f" {a}", file=sys.stderr)
|
||||
print(f"refused: no plan written to {out}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
mkdirs = sorted({os.path.dirname(dst) for _s, dst in rows},
|
||||
key=lambda p: (p.count(os.sep), p))
|
||||
with open(out, "w") as fh:
|
||||
fh.write("action\tsrc\tdst\tkind\n")
|
||||
for d in mkdirs:
|
||||
fh.write(f"mkdir\t\t{d}\tdir\n")
|
||||
for src, dst in rows:
|
||||
fh.write(f"move\t{src}\t{dst}\ttvdup\n")
|
||||
|
||||
held = sum(os.path.getsize(s) for s, _d in rows if os.path.isfile(s))
|
||||
print(f"plan: {len(rows)} moves ({held / 2**30:.1f} GiB) into"
|
||||
f" {len(mkdirs)} hold folders -> {out}")
|
||||
print("audit: every staged file has a survivor in the same show holding each of"
|
||||
" its episodes at >= its size on disk, one destination each")
|
||||
if rows:
|
||||
root = os.path.dirname(library)
|
||||
print(f"next: python3 scripts/media-apply.py apply --plan {out} --root"
|
||||
f" {root} --journal {os.path.join(root, 'tv-dupes.jsonl')}"
|
||||
" --metadata-view /var/lib/media-meta/view (dry run; add --apply)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user