Files
serverAppConfigs/scripts/media-ebook-dedupe.py
T
dank fab17129e2 feat(media): dedupe + relayout campaign tools for Audiobooks and eBooks
media-ab-reorg.py: planner for the ABS layout {Author}/{Series}/{Seq -
Year - Title {Narrator}} — claims one book folder per DB item (tracks
of duplicate recordings must not interleave), gates any book folder
that would nest above another (ABS's swallow defect), routes comic and
ebook-only items to intake, holds dedup losers under the campaign dir.
media-ab-verifydups.py: full-blake2b confirmation of size+head/tail
collision survivors. media-ebook-dedupe.py: dedupe the eBooks pile
against the Calibre library (identity key title-core+first-surname,
OPF metadata authority) and emit the calibredb ingest list.
abs-post-reorg.py: server-side ABS driver (cover export, DB-row wipe —
never hard=1 — scan, status) with credentials out of argv.
2026-11-04 14:45:29 -08:00

240 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""media-ebook-dedupe.py — dedupe the eBooks pile against the Calibre library.
2026-11-04 campaign, ebook side. The Calibre library (/mnt/entropy/CalibreBooks,
6,289 books) is DB-owned and healthy; /mnt/entropy/eBooks is a raw pile whose
best books already live in the library, several of them twice in the pile.
Audiobooks' orphaned ebooks/comics land in the pile first (media-ab-reorg's
intake), so this campaign runs after the audiobook move.
Inputs
--meta ebook-meta.tsv: JSON lines {p, sz, title, authors, ident} for
epub (embedded OPF) and mobi/azw3 (Palm header title); the
producer is the campaign's opfcensus.py.
--enum enum TSV (size, ext, path) for every tree.
--calibre calibre-books.tsv: JSON lines [id, title, authors, series, path,
pubdate, formats] dumped from metadata.db.
--dups full blake2b verdicts (hash, size, path) — only confirmed
byte-identities remove a file. Optional: without it, identity
rules alone run and every hash-dependent pair stays a KEEP.
Outputs
--plan media-apply plan (--root /mnt/entropy): hash-duplicates of a
file already in CalibreBooks -> HOLD; byte-identical files inside
the pile keep the best format of one book once; non-book junk
(mp4/mp3/images) -> PARK.
--ingest one JSON line per surviving BOOK (a set of format files of one
identity): {paths, title, authors, source}. The ingest runner
feeds these to calibredb inside the calibre-web image.
--exceptions every identity pair the file-level evidence could not settle.
Match law: identity is (core title, first author surname). Calibre's
"Dinniman| Matt" and an OPF "Matt Dinniman" share surname "dinniman". Title
core drops the leading article and all non-alphanumerics. Identity alone NEVER
deletes against the pile — it only deletes when the same identity sits in the
Calibre library with the same format (the pile copy adds nothing), or when a
byte-hash confirms the pair. Format preference when the pile holds one book in
several copies: epub > azw3 > mobi > pdf > fb2 > lit > rtf > txt.
"""
import argparse
import json
import os
import re
import unicodedata
from collections import defaultdict
BOOK_EXT = {"epub", "azw3", "mobi", "pdf", "fb2", "lit", "rtf", "txt", "cbr", "cbz"}
FORMAT_RANK = {"epub": 0, "azw3": 1, "mobi": 2, "pdf": 3, "fb2": 4,
"cbr": 5, "cbz": 5, "lit": 6, "rtf": 7, "txt": 8}
ARTICLE = re.compile(r"^(the|a|an)\s+", re.I)
def ascii_fold(s):
return "".join(c for c in unicodedata.normalize("NFKD", s or "")
if not unicodedata.combining(c))
def title_core(title):
t = ascii_fold(title).lower()
t = ARTICLE.sub("", t)
t = re.sub(r"\(.*?\)", " ", t) # (1998), (Unabridged) — noise
t = re.sub(r"[^a-z0-9 ]", " ", t)
t = re.sub(r"\b(1[5-9]|20)\d\d\b", " ", t) # years
return " ".join(t.split())[:120]
def surname(author):
a = ascii_fold(author).strip()
a = re.split(r"[;,]", a)[0] if "," in a else " ".join(a.split()[-1:])
return re.sub(r"[^a-z]", "", a.lower())
def first_author(authors):
parts = [p.strip() for p in re.split(r"[;&]", authors or "") if p.strip()]
return parts[0] if parts else ""
def stem_title(path):
s = os.path.splitext(os.path.basename(path))[0]
return ascii_fold(s)
class Campaign:
def __init__(self, args):
self.args = args
self.root = args.root.rstrip("/")
self.pile = self.root + "/eBooks"
self.lib = self.root + "/CalibreBooks"
self.hold = args.hold
self.park = args.park
self.meta = {} # path -> meta dict
self.pile_files = [] # (sz, ext, path)
self.cal_by_path = {} # file path under CalibreBooks -> calibre row
self.cal_identity = {} # (tcore, surname) -> calibre row
self.drop = {} # path -> reason
self.exc = defaultdict(list)
self.moves = []
self.dirs_pile_book = defaultdict(list) # book dir (pile) -> files
# ------------------------------------------------------------- inputs
def load(self):
for line in open(self.args.meta):
r = json.loads(line)
self.meta[r["p"]] = r
by_hash = defaultdict(list)
if self.args.dups:
for line in open(self.args.dups):
h, sz, p = line.rstrip("\n").split("\t", 2)
by_hash[h].append(p)
self.dup_sets = [ps for ps in by_hash.values() if len(ps) > 1]
for line in open(self.args.enum):
sz, ext, p = line.rstrip("\n").split("\t", 2)
ext = ext.lstrip(".").lower()
if p.startswith(self.pile + "/") and p not in self.park:
self.pile_files.append((int(sz), ext, p))
for line in open(self.args.calibre):
row = json.loads(line)
self.cal_identity[(title_core(row[1]), surname(first_author(row[2])))] = row
def identity(self, p):
m = self.meta.get(p)
if m and m.get("title"):
t = title_core(m["title"])
a = surname(first_author(m.get("authors", "")))
if t:
return (t, a)
return (title_core(stem_title(p)), "")
# ------------------------------------------------------------- rules
def run(self):
pile_paths = {p for _, _, p in self.pile_files}
# R1/R2: confirmed byte-duplicates
for ps in self.dup_sets:
in_lib = [p for p in ps if p.startswith(self.lib + "/")]
in_pile = [p for p in ps if p.startswith(self.pile + "/")]
if in_lib:
for p in in_pile:
self.drop[p] = "hash-duplicate of CalibreBooks file"
if len(in_pile) > 1:
keep = min(in_pile, key=lambda p: (FORMAT_RANK.get(
os.path.splitext(p)[1].lstrip(".").lower(), 9), len(p), p))
for p in in_pile:
if p != keep:
self.drop[p] = "hash-duplicate inside the pile"
# R3/R4: identity against the library (same format only deletes)
cal_files = defaultdict(set) # identity -> formats in the library
for key, row in self.cal_identity.items():
cal_files[key] = set(f for f in row[6].split("; ") if f)
for sz, ext, p in self.pile_files:
if p in self.drop or ext not in BOOK_EXT:
continue
key = self.identity(p)
if key[0] and key in self.cal_identity:
if ext in cal_files[key]:
self.drop[p] = f"same identity+format already in Calibre ({key[0][:40]})"
else:
self.exc["pile holds a format the library lacks (add-format on ingest)"].append(
f"{p}\t{key[0][:50]}\t{self.cal_identity[key][0]}")
# R5: surviving files grouped by (identity, parent-ish book dir)
books = defaultdict(list)
for sz, ext, p in self.pile_files:
if p in self.drop:
continue
if ext not in BOOK_EXT:
self.moves.append((p, os.path.join(self.park, os.path.relpath(p, self.root)),
"non-book"))
continue
m = self.meta.get(p, {})
if m.get("err") and ext in ("epub",):
self.exc["unreadable container (park for a human)"].append(p)
self.moves.append((p, os.path.join(self.park, os.path.relpath(p, self.root)),
"broken-book"))
continue
key = self.identity(p)
books[key].append(p)
for key, ps in books.items():
first = min(ps, key=lambda p: (FORMAT_RANK.get(
os.path.splitext(p)[1].lstrip(".").lower(), 9), len(p), p))
m = self.meta.get(first, {})
self.ingest = {
"key": list(key),
"paths": sorted(ps),
"title": m.get("title") or stem_title(first),
"authors": m.get("authors") or "",
"ident": m.get("ident") or "",
}
self.ingest_list.append(self.ingest)
for p, why in self.drop.items():
self.moves.append((p, os.path.join(self.hold, os.path.relpath(p, self.root)), "dup"))
def write(self):
dirs = {os.path.dirname(d) for _, d, _ in self.moves}
all_dirs = set()
for d in dirs:
while d and d != self.root:
all_dirs.add(d)
d = os.path.dirname(d)
with open(self.args.plan, "w") as fh:
fh.write("action\tsrc\tdst\tkind\n")
for d in sorted(all_dirs):
fh.write(f"mkdir\t\t{d}\tcampaign\n")
for src, dst, kind in sorted(self.moves):
fh.write(f"move\t{src}\t{dst}\t{kind}\n")
with open(self.args.ingest, "w") as fh:
for b in self.ingest_list:
fh.write(json.dumps(b, ensure_ascii=False) + "\n")
with open(self.args.exceptions, "w") as fh:
for why in sorted(self.exc):
fh.write(f"# {why}\n")
for line in sorted(set(self.exc[why])):
fh.write(line + "\n")
fh.write("\n")
ndrop = sum(1 for _, _, k in self.moves if k == "dup")
print(f"pile books to ingest: {len(self.ingest_list)} "
f"files dropped as duplicates: {ndrop} "
f"non-book/broken moved out: {sum(1 for _, _, k in self.moves if k in ('non-book', 'broken-book'))}")
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--root", default="/mnt/entropy")
ap.add_argument("--meta", required=True)
ap.add_argument("--enum", required=True)
ap.add_argument("--calibre", required=True)
ap.add_argument("--dups", default=None)
ap.add_argument("--hold", default="/mnt/entropy/.media-campaign/dup-hold-ebooks-20261104")
ap.add_argument("--park", default="/mnt/entropy/NeedsProcessing/_from-ebooks-20261104")
ap.add_argument("--plan", required=True)
ap.add_argument("--ingest", required=True)
ap.add_argument("--exceptions", required=True)
args = ap.parse_args()
c = Campaign(args)
c.ingest_list = []
c.load()
c.run()
c.write()
if __name__ == "__main__":
main()