Best-pool scoring used to always blend the archive's inner file names into the fuzzy score (0.95 discount), which dragged clean-named files into wrong units (e.g. 'Keeper of Secrets & Shalaxi Helbane.7z' ended in Orks/Boyz). Now the outer name decides when it scores >= threshold; inner names only vote as fallback (hash-named gofile archives rely on that). Also: url/failed-list run artifacts are excluded from sorting, and two plan-inspection helpers under tools/.
384 lines
15 KiB
Python
384 lines
15 KiB
Python
"""Sorter: place collection files into the library via plan/apply.
|
|
|
|
See docs/SORTING.md for the structure and the pipeline this implements:
|
|
inspect (name + archive listing) -> classify (keyword tables) -> fuzzy match
|
|
against the library catalog -> plan -> apply (hardlink, fallback copy).
|
|
|
|
The sorter never deletes or modifies source files. Applying is append-only:
|
|
targets that already exist are skipped, actions are audited in
|
|
sorter_applied.jsonl.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import zipfile
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from dlphelper.catalogue import _clean_name, _match_class, _match_faction
|
|
from dlphelper.warhammer_data import GROUP_OF_FACTION
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
ARCHIVE_SUFFIXES = {".zip", ".7z", ".rar", ".tar", ".gz"}
|
|
UNSORTED = "_unsorted"
|
|
UNCLASSIFIED_CLASS = "Unclassified"
|
|
|
|
# Run-artifact files that must never be sorted into a library
|
|
RUN_ARTIFACTS = {
|
|
"export.json", "export.html", "state.json", "manifest.jsonl",
|
|
"failed_urls.txt", "catalogue.md", "catalogue.jsonl",
|
|
"sorter_applied.jsonl", "catalogue_moves.jsonl", "urls.txt",
|
|
"urls_0807.txt", "urls_gofile.txt", "failed_0807.txt",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class SortItem:
|
|
"""One file to sort, plus everything learned about it."""
|
|
|
|
path: Path
|
|
size: int
|
|
name: str # original file name
|
|
clean: str # cleaned lower-case name
|
|
inner_names: list[str] = field(default_factory=list) # archive listing
|
|
system: str = "Unknown"
|
|
group: str = UNSORTED
|
|
faction: str = UNSORTED
|
|
subfaction: str | None = None
|
|
klass: str | None = None
|
|
unit: str | None = None # canonical catalog name, if matched
|
|
match_score: float = 0.0
|
|
match_kind: str = "none" # catalog | implicit | unclassified
|
|
action: str = "link" # link | copy
|
|
target: Path | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# inspection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def list_zip(path: Path, limit: int = 40) -> list[str]:
|
|
"""Inner names of a .zip without extracting."""
|
|
try:
|
|
with zipfile.ZipFile(path) as zf:
|
|
return zf.namelist()[:limit]
|
|
except (zipfile.BadZipFile, OSError, RuntimeError) as exc:
|
|
log.debug("zip listing failed for %s: %s", path.name, exc)
|
|
return []
|
|
|
|
|
|
def list_7z(path: Path, limit: int = 40) -> list[str]:
|
|
"""Inner names of a .7z via the installed 7z binary, without extracting."""
|
|
if not shutil.which("7z"):
|
|
return []
|
|
try:
|
|
proc = subprocess.run(
|
|
["7z", "l", "-ba", "-slt", str(path)],
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=120,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
log.debug("7z listing failed for %s: %s", path.name, exc)
|
|
return []
|
|
names: list[str] = []
|
|
for block in proc.stdout.split("Path = ")[1:]:
|
|
name = block.splitlines()[0].strip() if block.splitlines() else ""
|
|
# -slt lists directories too; skip entries ending in /
|
|
if name and not name.endswith("/"):
|
|
names.append(name)
|
|
if len(names) >= limit:
|
|
break
|
|
return names
|
|
|
|
|
|
def inspect(path: Path) -> SortItem:
|
|
size = path.stat().st_size if path.exists() else 0
|
|
item = SortItem(path=path, size=size, name=path.name, clean=_clean_name(path.name))
|
|
if not path.is_file():
|
|
return item
|
|
if path.suffix.lower() in ARCHIVE_SUFFIXES:
|
|
# An archive that is not an archive is almost always a saved HTML
|
|
# error/login page that got downloaded instead of the file.
|
|
if path.suffix.lower() == ".zip":
|
|
item.inner_names = list_zip(path)
|
|
else:
|
|
item.inner_names = list_7z(path)
|
|
if item.inner_names == [] and path.stat().st_size > 0:
|
|
head = b""
|
|
with path.open("rb") as fh:
|
|
head = fh.read(512)
|
|
if b"<!doctype html" in head.lower() or b"<html" in head.lower():
|
|
log.warning("%s is an HTML page, not an archive (bad download)", path.name)
|
|
item.match_kind = "invalid"
|
|
return item
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# classification & matching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def classify_item(item: SortItem) -> None:
|
|
"""Keyword classification from the file name, then refined by inner names."""
|
|
hit = _match_faction(item.clean)
|
|
if hit:
|
|
item.system, item.faction = hit
|
|
item.klass = _match_class(item.clean) or (
|
|
_match_class(_clean_name(" ".join(item.inner_names))) if item.inner_names else None
|
|
)
|
|
|
|
if item.faction == UNSORTED and item.inner_names:
|
|
# hash-named archives: vote on the inner names' classification
|
|
votes: dict[tuple[str, str], int] = {}
|
|
for inner in item.inner_names:
|
|
ihit = _match_faction(_clean_name(inner))
|
|
if ihit:
|
|
votes[ihit] = votes.get(ihit, 0) + 1
|
|
if votes:
|
|
best = max(votes.items(), key=lambda kv: kv[1])[0]
|
|
item.system, item.faction = best
|
|
if item.klass is None:
|
|
inner_classes = [
|
|
_match_class(_clean_name(inner)) for inner in item.inner_names
|
|
]
|
|
cvotes: dict[str, int] = {}
|
|
for c in inner_classes:
|
|
if c:
|
|
cvotes[c] = cvotes.get(c, 0) + 1
|
|
if cvotes:
|
|
item.klass = max(cvotes.items(), key=lambda kv: kv[1])[0]
|
|
|
|
item.group = GROUP_OF_FACTION.get(item.faction, UNSORTED)
|
|
|
|
|
|
# faction group layer (GW naming, shared with warhammer_data)
|
|
|
|
|
|
def load_catalog(path: Path) -> list[dict]:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
entries = data["entries"] if isinstance(data, dict) else data
|
|
for e in entries:
|
|
e["_clean"] = _clean_name(e["name"])
|
|
e["_clean_aliases"] = [_clean_name(a) for a in e.get("aliases", [])]
|
|
return entries
|
|
|
|
|
|
def _best_ratio(clean: str, candidates: list[str]) -> float:
|
|
return max(
|
|
(difflib.SequenceMatcher(None, clean, c).ratio() for c in candidates),
|
|
default=0.0,
|
|
)
|
|
|
|
|
|
def _best_in_pool(
|
|
item: SortItem, pool: list[dict], use_inner: bool = True
|
|
) -> tuple[float, dict] | None:
|
|
"""Best fuzzy score of `item` against all entries in `pool`.
|
|
|
|
Inner archive names are only consulted on request; a confident outer
|
|
(file) name must win over votes from random inner STL filenames.
|
|
"""
|
|
best: tuple[float, dict] | None = None
|
|
for entry in pool:
|
|
cands = [entry["_clean"], *entry["_clean_aliases"]]
|
|
score = _best_ratio(item.clean, cands)
|
|
if use_inner and item.inner_names:
|
|
inner_clean = [_clean_name(n) for n in item.inner_names[:10]]
|
|
for c in inner_clean:
|
|
score = max(score, _best_ratio(c, cands) * 0.95) # inner slightly discounted
|
|
if best is None or score > best[0]:
|
|
best = (score, entry)
|
|
return best
|
|
|
|
|
|
def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None:
|
|
"""Fuzzy-match against catalog entries in the same faction (or all).
|
|
|
|
The matched entry is hierarchy-authoritative: group, subfaction and
|
|
class come from the catalog (GW shop categories), and when the keyword
|
|
classification had no faction the entry's system/faction is adopted too.
|
|
"""
|
|
if item.faction == UNSORTED and not item.inner_names and not item.clean:
|
|
return
|
|
if item.match_kind == "invalid":
|
|
return # HTML-page masquerading as an archive; never fuzzy-match it
|
|
same_faction = [e for e in catalog if e.get("faction") == item.faction]
|
|
pool = same_faction or catalog # fall back to whole catalog if faction unknown
|
|
best = _best_in_pool(item, pool, use_inner=False)
|
|
if best is None or best[0] < threshold:
|
|
# Outer name not confident: let the archive's inner file names vote
|
|
# (this is the only signal for hash-named gofile archives).
|
|
best = _best_in_pool(item, pool, use_inner=True)
|
|
if best is None or best[0] < threshold:
|
|
# The keyword tables can lag behind GW's current categorization
|
|
# (e.g. units moved between factions); if the faction pool has no
|
|
# confident match, try the whole catalog before giving up.
|
|
if same_faction:
|
|
best = _best_in_pool(item, catalog, use_inner=False)
|
|
if best is None or best[0] < threshold:
|
|
best = _best_in_pool(item, catalog, use_inner=True)
|
|
if best and best[0] >= threshold:
|
|
entry = best[1]
|
|
item.unit = entry["name"]
|
|
if entry.get("group"):
|
|
item.group = entry["group"]
|
|
elif item.faction == UNSORTED:
|
|
item.group = GROUP_OF_FACTION.get(entry["faction"], UNSORTED)
|
|
if entry.get("subfaction"):
|
|
item.subfaction = entry["subfaction"]
|
|
# GW's class layer (shop category) beats the keyword guess, but do
|
|
# not *narrow* it: "Battleline" is GW's bucket for core infantry,
|
|
# a keyword-derived Hero/Elite is more specific for a proxy file.
|
|
catalog_klass = entry.get("class")
|
|
if catalog_klass and item.klass in (None, "", UNCLASSIFIED_CLASS):
|
|
item.klass = catalog_klass
|
|
item.match_score = best[0]
|
|
item.match_kind = "catalog"
|
|
# The catalog entry's faction is authoritative - but only adopt it
|
|
# when it is a real army faction. Generic GW branches like
|
|
# "Unit Type" or factionless entries would degrade a confident
|
|
# keyword faction to a junk folder.
|
|
entry_faction = entry.get("faction")
|
|
generic_factions = {"Unit Type", "Heroes", "Warriors", "Character"}
|
|
if item.faction == UNSORTED:
|
|
if entry_faction and entry_faction not in generic_factions:
|
|
item.system = entry["system"]
|
|
item.faction = entry_faction
|
|
if entry.get("group") and entry["group"] not in ("Unit Type",):
|
|
item.group = entry["group"]
|
|
else:
|
|
item.group = GROUP_OF_FACTION.get(item.faction, UNSORTED)
|
|
elif entry_faction and entry_faction not in generic_factions and entry_faction != item.faction:
|
|
# GW moved this unit to another faction (or the keyword tables
|
|
# are stale): trust the catalog.
|
|
item.system = entry["system"]
|
|
item.faction = entry_faction
|
|
if entry.get("group") and entry["group"] != "Unit Type":
|
|
item.group = entry["group"]
|
|
else:
|
|
item.group = GROUP_OF_FACTION.get(entry_faction, UNSORTED)
|
|
elif entry.get("group") and entry["group"] not in ("Unit Type",):
|
|
item.group = entry["group"]
|
|
else:
|
|
if item.faction == UNSORTED:
|
|
item.unit = item.name # implicit entry: own (original) name
|
|
item.match_score = best[0] if best else 0.0
|
|
item.match_kind = "implicit"
|
|
else:
|
|
# Faction was known but nothing fuzzy-matched: keep the keyword
|
|
# faction and use the file's own name as the unit, but adopt the
|
|
# catalog's class layer when it adds information.
|
|
item.unit = item.name
|
|
item.match_score = best[0] if best else 0.0
|
|
item.match_kind = "implicit"
|
|
|
|
|
|
def build_target(item: SortItem, library: Path) -> Path:
|
|
parts: list[str] = []
|
|
if item.system == "Unknown":
|
|
parts = [UNSORTED]
|
|
else:
|
|
parts = [item.system]
|
|
if item.faction != UNSORTED:
|
|
if item.group:
|
|
parts.append(item.group)
|
|
parts.append(item.faction)
|
|
if item.subfaction:
|
|
parts.append(item.subfaction)
|
|
parts.append(item.klass or UNCLASSIFIED_CLASS)
|
|
if item.unit:
|
|
parts.append(_display_name(item.unit))
|
|
else:
|
|
parts.append(UNSORTED)
|
|
target_dir = Path(library).joinpath(*parts)
|
|
return target_dir / item.name
|
|
|
|
|
|
def _display_name(unit: str) -> str:
|
|
"""Implicit entries carry the original filename; strip the extension."""
|
|
p = Path(unit)
|
|
return p.stem if p.suffix else unit
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# plan / apply
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def sort_plan(
|
|
files: list[Path],
|
|
library: Path,
|
|
catalog: list[dict],
|
|
threshold: float = 0.62,
|
|
) -> list[SortItem]:
|
|
items = []
|
|
for f in sorted(files):
|
|
if not f.is_file():
|
|
continue
|
|
if (f.name.endswith(".part") or f.name.startswith(".")
|
|
or f.name in RUN_ARTIFACTS):
|
|
continue
|
|
item = inspect(f)
|
|
classify_item(item)
|
|
match_catalog(item, catalog, threshold)
|
|
item.target = build_target(item, library)
|
|
item.action = "link"
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def apply_plan(items: list[SortItem], audit_path: Path) -> tuple[int, int, int]:
|
|
"""Create targets. Hardlink preferred; copy when filesystems differ.
|
|
|
|
Returns (linked, copied, skipped).
|
|
"""
|
|
audit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
linked = copied = skipped = 0
|
|
with audit_path.open("a", encoding="utf-8") as audit:
|
|
for item in items:
|
|
src, dest = item.path, item.target
|
|
if item.match_kind == "invalid":
|
|
log.error("Not sorting %s: it is an HTML page, not an archive", src.name)
|
|
skipped += 1
|
|
continue
|
|
if dest is None or not src.is_file():
|
|
skipped += 1
|
|
continue
|
|
if dest.exists():
|
|
log.info("Skip, target exists: %s", dest)
|
|
skipped += 1
|
|
continue
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if os.stat(src).st_dev == os.stat(dest.parent).st_dev:
|
|
os.link(src, dest)
|
|
item.action = "link"
|
|
linked += 1
|
|
else:
|
|
log.warning(
|
|
"Different filesystem - COPYING instead of hardlinking %s", src.name
|
|
)
|
|
shutil.copy2(src, dest)
|
|
item.action = "copy"
|
|
copied += 1
|
|
audit.write(json.dumps({
|
|
"from": str(src), "to": str(dest), "action": item.action,
|
|
"unit": item.unit, "faction": item.faction, "class": item.klass,
|
|
}) + "\n")
|
|
return linked, copied, skipped
|
|
|
|
|
|
def format_plan(items: list[SortItem]) -> str:
|
|
lines = []
|
|
for item in items:
|
|
if item.match_kind == "invalid":
|
|
lines.append(f"[x] {item.name} - NOT AN ARCHIVE (HTML page, bad download)")
|
|
continue
|
|
src = f"{item.name} ({item.size // (1024 * 1024)} MiB)" if item.size > 4 * 1024 * 1024 else item.name
|
|
flag = {"catalog": "+", "implicit": "~", "none": "?"}[item.match_kind]
|
|
lines.append(f"[{flag}] {src}\n -> {item.target}")
|
|
return "\n".join(lines)
|