Phase 2: PS1 library scan, settings, conversion queue, and PS1 tab
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user