"""PS1 library helpers: discover source discs and converted EBOOT.PBPs. This is the read/scan side of PS1 support (the conversion side lives in ``app.ps1_convert``). It finds raw PS1 disc images (``.cue`` sets and standalone ``.iso``/``.img``), reads each disc's boot serial, and lists converted ``EBOOT.PBP`` files in the output folder. """ from __future__ import annotations import re from pathlib import Path from typing import List, Optional from . import config RAW_SECTOR = 2352 COOKED_SECTOR = 2048 EBOOT_NAME = "EBOOT.PBP" # SYSTEM.CNF boot line: cdrom:\SLPS_017.62;1 (backslash optional in some rips) _SERIAL_RE = re.compile(rb"cdrom:\\?([A-Za-z]{4})[_-]?(\d{3})\.(\d{2})") def _sector_size(path: Path) -> int: size = path.stat().st_size if size and size % RAW_SECTOR == 0: return RAW_SECTOR return COOKED_SECTOR def read_serial(image_path: Path) -> Optional[str]: """Read the boot serial (e.g. ``SLPS01762``) from a PS1 disc image. Works on both raw 2352-byte/sector images (BIN/IMG) and cooked 2048-byte images (ISO). Returns ``None`` if the boot line can't be found. """ image_path = Path(image_path) if not image_path.is_file(): return None sector_size = _sector_size(image_path) data_off = 24 if sector_size == RAW_SECTOR else 0 buf = bytearray() with open(image_path, "rb") as fh: for _ in range(128): # SYSTEM.CNF is always near the start of the disc sector = fh.read(sector_size) if len(sector) < sector_size: break buf += sector[data_off:data_off + COOKED_SECTOR] m = _SERIAL_RE.search(bytes(buf)) if m: return (m.group(1) + m.group(2) + m.group(3)).decode().upper() return None def cue_data_track(cue_path: Path) -> Optional[Path]: """Return the first data-track image referenced by a cue sheet.""" from . import ps1_convert try: disc = ps1_convert.parse_cue(cue_path.read_text(encoding="utf-8", errors="replace")) except Exception: return None track = disc.data_track if track is None: return None return (cue_path.parent / track.file) if track.file else None def iter_source_discs(root: Path): """Yield PS1 source discs under ``root`` (cue sheets + standalone images).""" root = Path(root) if not root.is_dir(): return for path in sorted(root.rglob("*")): if not path.is_file(): continue suffix = path.suffix.lower() if suffix == ".cue": yield path elif suffix in (".iso", ".img"): # A standalone image: no cue sheet next to it (same stem). if not (path.parent / f"{path.stem}.cue").exists(): yield path def find_eboots(root: Path) -> List[Path]: """List converted/PSN ``EBOOT.PBP`` files under ``root``.""" root = Path(root) if not root.is_dir(): return [] return [p for p in sorted(root.rglob("*")) if p.is_file() and p.name.upper() == EBOOT_NAME] def eboot_title(pbp_path: Path) -> str: """Read the game title embedded in an EBOOT.PBP (falls back to folder name).""" from . import iso_reader, ps1_convert try: header = ps1_convert.read_pbp_header(pbp_path) idx = ps1_convert.SECTION_OFFSET["sfo"] // 4 sfo = ps1_convert.read_pbp_section(pbp_path, header, idx) parsed = iso_reader.parse_sfo(sfo) title = parsed.get("TITLE") or parsed.get("TITLE_0") or parsed.get("TITLE_00") if title: return str(title) except Exception: pass return pbp_path.parent.name def scan_library(ps1_dir, output_dir) -> List[dict]: """Return a combined view of PS1 source discs and converted EBOOTs. Each entry is a dict with ``kind`` ("disc" or "eboot"), ``path``, ``name``, ``serial`` and ``status`` ("pending" / "converted"). """ ps1_dir = Path(ps1_dir) output_dir = Path(output_dir) eboots = find_eboots(output_dir) eboot_names = {e.name.upper() for e in eboots} eboot_titles = {eboot_title(e).lower() for e in eboots} entries: List[dict] = [] for src in iter_source_discs(ps1_dir): if src.suffix.lower() == ".cue": image = cue_data_track(src) or src else: image = src serial = read_serial(image) title = src.stem # A disc is "converted" when an EBOOT's embedded title or filename # matches the source disc's title/serial. status = "pending" if title.lower() in eboot_titles or (serial and any(serial.lower() in t for t in eboot_titles)): status = "converted" entries.append({ "kind": "disc", "path": str(src), "name": title, "serial": serial or "", "status": status, }) for e in eboots: entries.append({ "kind": "eboot", "path": str(e), "name": eboot_title(e), "serial": "", "status": "converted", }) return entries