Files
2026-09-06 22:30:13 -04:00

159 lines
5.2 KiB
Python

"""Scan directories for PSP .iso discs and read their identity + hashes.
A persistent scan cache (in the library DB) keyed by file size + mtime means
re-scanning only re-hashes files that actually changed; unchanged discs are
served from cache instantly. Hashing of new/changed files runs in parallel.
"""
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from . import config, hasher, iso_reader
log = logging.getLogger("psp.scan")
def iter_discs(roots):
seen = set()
for root in roots:
root = Path(root)
if not root.exists():
continue
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
suffix = path.suffix.lower()
if suffix in config.IGNORED_EXTENSIONS:
continue
if suffix not in config.DISC_EXTENSIONS:
continue
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
yield path
def _process_disc(path, hash_files=True, cancel=None):
stat = path.stat()
try:
identity = iso_reader.read_identity(path)
serial = identity.get("serial")
title = identity.get("title")
error = None
except Exception as exc: # noqa: BLE001 - keep scanning on bad discs
serial = None
title = None
error = f"{type(exc).__name__}: {exc}"
if hash_files:
hashes = hasher.hash_file(path, cancel=cancel)
else:
hashes = {"crc32": None, "md5": None, "sha1": None}
return {
"path": str(path),
"name": path.name,
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
"serial": serial,
"title": title,
"error": error,
**hashes,
}
def _cached_record(conn, path, stat, need_hashes=False):
if conn is None:
return None
row = conn.execute(
"SELECT size, mtime_ns, serial, title, error, crc32, md5, sha1 FROM files WHERE path = ?",
(str(path),),
).fetchone()
if row is None or row["size"] != stat.st_size or row["mtime_ns"] != stat.st_mtime_ns:
return None
if need_hashes and not row["crc32"]:
return None
return {
"path": str(path),
"name": path.name,
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
"serial": row["serial"],
"title": row["title"],
"error": row["error"],
"crc32": row["crc32"],
"md5": row["md5"],
"sha1": row["sha1"],
}
def _upsert(conn, rec):
conn.execute(
"""
INSERT INTO files (path, name, size, mtime_ns, crc32, md5, sha1, serial, title, error, scanned_at)
VALUES (?,?,?,?,?,?,?,?,?,?,datetime('now'))
ON CONFLICT(path) DO UPDATE SET
name=excluded.name, size=excluded.size, mtime_ns=excluded.mtime_ns,
crc32=excluded.crc32, md5=excluded.md5, sha1=excluded.sha1,
serial=excluded.serial, title=excluded.title, error=excluded.error,
scanned_at=excluded.scanned_at
""",
(rec["path"], rec["name"], rec["size"], rec["mtime_ns"], rec["crc32"], rec["md5"], rec["sha1"], rec["serial"], rec["title"], rec["error"]),
)
def rename_and_recache(conn, src, dst):
"""Rename a disc and carry its cache entry over so it isn't re-hashed."""
src.rename(dst)
if conn is not None:
conn.execute(
"UPDATE files SET path = ?, name = ? WHERE path = ?",
(str(dst), dst.name, str(src)),
)
def scan(roots, conn=None, workers=1, hash_files=True, cancel=None):
pending = []
cached_count = 0
for path in iter_discs(roots):
if cancel is not None and cancel.is_set():
return
stat = path.stat()
cached = _cached_record(conn, path, stat, need_hashes=hash_files)
if cached is not None:
cached_count += 1
log.info("cached %s", path.name)
yield cached
continue
pending.append(path)
if cached_count:
log.info("%d disc(s) served from cache", cached_count)
if not pending:
return
log.info("processing %d disc(s) with %d worker(s)...", len(pending), workers)
try:
if workers and workers > 1:
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(_process_disc, p, hash_files, cancel): p for p in pending}
for future in as_completed(futures):
if cancel is not None and cancel.is_set():
return
rec = future.result()
log.info("scanned %s", rec["name"])
if conn is not None:
_upsert(conn, rec)
yield rec
else:
for path in pending:
if cancel is not None and cancel.is_set():
return
rec = _process_disc(path, hash_files, cancel=cancel)
log.info("scanned %s", rec["name"])
if conn is not None:
_upsert(conn, rec)
yield rec
except hasher.HashCancelled:
return