"""Scan, match, and report on PSP discs. Dry-run by default. Usage:: python3 -m app.scan --roots /mnt/user/psp/ISO python3 -m app.scan --roots /mnt/user/psp/ISO --apply # actually rename """ import argparse import json import logging import sys from pathlib import Path from . import config, pipeline, scanner from .db import init_db def _roots_from_arg(value): if value: raw = value else: raw = ",".join(map(str, config.GAMES_ROOTS + config.VIDEO_ROOTS)) return [Path(p) for p in raw.split(",") if p.strip()] def main(argv=None): logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s", stream=sys.stderr) ap = argparse.ArgumentParser(description="Scan PSP .iso discs and match against the catalog") ap.add_argument("--roots", default=None, help="comma-separated directories to scan") ap.add_argument("--db", default=None, help="SQLite DB path") ap.add_argument("--apply", action="store_true", help="apply renames (default: dry-run)") ap.add_argument("--json", action="store_true", help="output JSON") ap.add_argument("--workers", type=int, default=config.SCAN_WORKERS, help="parallel hashing workers") ap.add_argument("--quick", action="store_true", help="skip full-file hashing (serial/title match only)") args = ap.parse_args(argv) roots = _roots_from_arg(args.roots) db_path = Path(args.db) if args.db else config.DB_PATH conn = init_db(db_path) try: results = [] for record in scanner.scan(roots, conn=conn, workers=args.workers, hash_files=not args.quick): ident = pipeline.identify(conn, record) results.append({**record, **ident}) if not args.json: if ident["method"] == "unknown": print(f"UNKNOWN {record['name']} serial={record['serial']} error={record['error']}", flush=True) else: print(f"MATCH[{ident['method']:14s}] {record['name']} -> {ident['canonical']}", flush=True) totals = { row["system"]: row["c"] for row in conn.execute( "SELECT d.system, COUNT(*) AS c FROM releases r JOIN dats d ON d.id = r.dat_id GROUP BY d.system" ) } on_disk = {} for r in results: if r["release"]: on_disk.setdefault(r["release"]["system"], set()).add(r["release"]["id"]) if args.json: print(json.dumps( { "catalog_totals": totals, "on_disk": {s: len(ids) for s, ids in on_disk.items()}, "missing": {s: totals.get(s, 0) - len(ids) for s, ids in on_disk.items()}, "discs": [ {k: r.get(k) for k in ("name", "size", "serial", "title", "method", "canonical", "error")} for r in results ], }, indent=2, )) return 0 matched = [r for r in results if r["method"] != "unknown"] unknown = [r for r in results if r["method"] == "unknown"] print(f"\nScanned: {len(results)} Matched: {len(matched)} Unknown: {len(unknown)}", flush=True) for system in sorted(totals): have = len(on_disk.get(system, set())) total = totals.get(system, 0) print(f"Catalog[{system}]: on-disk {have}/{total} missing {total - have}") renames = [ r for r in results if r["method"] != "unknown" and r["canonical"] and Path(r["path"]).name != r["canonical"] ] if renames: print(f"\nRename plan ({len(renames)} file(s)):") for r in renames: print(f" {Path(r['path']).name}\n -> {r['canonical']}") if args.apply: for r in renames: src = Path(r["path"]) dst = src.with_name(r["canonical"]) if src.exists() and not dst.exists(): scanner.rename_and_recache(conn, src, dst) print(f" renamed -> {dst.name}") else: print(" (dry-run; pass --apply to rename)") conn.commit() finally: conn.close() return 0 if __name__ == "__main__": raise SystemExit(main())