388 lines
12 KiB
Python
388 lines
12 KiB
Python
"""PSP device detection and memory-stick inventory.
|
|
|
|
The PSP mounts as a plain USB mass-storage device whose root contains a
|
|
``PSP/`` folder (and, on CFW, an ``ISO/`` folder). This module finds mounted
|
|
PSP sticks, lists what is installed on them, and reports free space.
|
|
"""
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from . import matcher
|
|
|
|
|
|
def _candidate_bases():
|
|
bases = ["/media", "/run/media", "/mnt"]
|
|
user = os.environ.get("USER")
|
|
if user:
|
|
bases += [f"/media/{user}", f"/run/media/{user}"]
|
|
return list(dict.fromkeys(bases))
|
|
|
|
|
|
def is_psp_root(path):
|
|
"""True if ``path`` looks like the root of a PSP memory stick.
|
|
|
|
A real stick has a ``PSP`` folder containing the firmware-created ``GAME``
|
|
or ``SYSTEM`` subfolders, and CFW sticks also have an ``ISO`` folder. A
|
|
plain folder named ``psp`` that only holds saves (e.g. a cloud-save
|
|
collection) is *not* a memory stick, so it is rejected.
|
|
"""
|
|
p = Path(path)
|
|
if not p.is_dir():
|
|
return False
|
|
psp_dir = p / "PSP"
|
|
if psp_dir.is_dir():
|
|
return (psp_dir / "GAME").is_dir() or (psp_dir / "SYSTEM").is_dir()
|
|
return (p / "ISO").is_dir()
|
|
|
|
|
|
def detect_mounts():
|
|
"""Return a list of mounted filesystem roots that look like PSP sticks."""
|
|
found = []
|
|
seen = set()
|
|
for base in _candidate_bases():
|
|
base_path = Path(base)
|
|
if not base_path.is_dir():
|
|
continue
|
|
try:
|
|
children = list(base_path.iterdir())
|
|
except OSError:
|
|
continue
|
|
for child in sorted(children):
|
|
if not child.is_dir():
|
|
continue
|
|
try:
|
|
key = str(child.resolve())
|
|
except OSError:
|
|
key = str(child)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
if is_psp_root(child):
|
|
found.append(str(child))
|
|
return found
|
|
|
|
|
|
def free_space(root):
|
|
"""Return ``{total, used, free}`` bytes, or None on failure."""
|
|
try:
|
|
usage = shutil.disk_usage(root)
|
|
except OSError:
|
|
return None
|
|
return {"total": usage.total, "used": usage.used, "free": usage.free}
|
|
|
|
|
|
def _file_item(path, kind):
|
|
try:
|
|
st = path.stat()
|
|
size = st.st_size
|
|
mtime = int(st.st_mtime)
|
|
except OSError:
|
|
size = 0
|
|
mtime = 0
|
|
return {"name": path.name, "path": str(path), "size": size, "mtime": mtime, "kind": kind}
|
|
|
|
|
|
DISC_SUFFIXES = {".iso", ".cso"}
|
|
|
|
|
|
def _iso_items(root):
|
|
"""Split installed disc images into games (ISO/), videos (ISO/VIDEO/), and
|
|
homebrew ISOs (ISO/Homebrew/, used by Category Lite). Handles both ``.iso``
|
|
and compressed ``.cso`` images."""
|
|
games, videos, homebrew = [], [], []
|
|
iso_dir = root / "ISO"
|
|
if iso_dir.is_dir():
|
|
for path in sorted(iso_dir.rglob("*")):
|
|
if not path.is_file() or path.suffix.lower() not in DISC_SUFFIXES:
|
|
continue
|
|
rel = path.relative_to(iso_dir)
|
|
first = rel.parts[0].lower() if rel.parts else ""
|
|
if first in ("video", "videos"):
|
|
videos.append(_file_item(path, "video"))
|
|
elif "homebrew" in first:
|
|
# Category Lite may use "Homebrew", "CAT_Homebrew", etc.
|
|
homebrew.append(_file_item(path, "homebrew_iso"))
|
|
else:
|
|
item = _file_item(path, "game")
|
|
item["category"] = rel.parts[0] if len(rel.parts) > 1 else ""
|
|
games.append(item)
|
|
|
|
# UMD video ISOs may also live in PSP/VIDEO on some CFW setups.
|
|
psp_video = root / "PSP" / "VIDEO"
|
|
if psp_video.is_dir():
|
|
for path in sorted(psp_video.rglob("*")):
|
|
if path.is_file() and path.suffix.lower() in DISC_SUFFIXES:
|
|
videos.append(_file_item(path, "video"))
|
|
return games, videos, homebrew
|
|
|
|
|
|
def _read_pbp_param_sfo(pbp_path):
|
|
"""Extract the PARAM.SFO blob embedded in an EBOOT.PBP, or None."""
|
|
try:
|
|
with open(pbp_path, "rb") as fh:
|
|
header = fh.read(0x28)
|
|
except OSError:
|
|
return None
|
|
if len(header) < 0x28 or header[:4] != b"\x00PBP":
|
|
return None
|
|
offsets = struct.unpack_from("<8I", header, 0x08)
|
|
start, end = offsets[0], offsets[1]
|
|
if end <= start:
|
|
return None
|
|
try:
|
|
with open(pbp_path, "rb") as fh:
|
|
fh.seek(start)
|
|
return fh.read(end - start)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _sfo_strings(data):
|
|
"""Parse a PARAM.SFO blob into a ``{key: value}`` dict of strings/ints."""
|
|
out = {}
|
|
if len(data) < 20 or data[:4] != b"\x00PSF":
|
|
return out
|
|
_version, key_table, data_table, count = struct.unpack_from("<IIII", data, 0x04)
|
|
if count <= 0:
|
|
return out
|
|
size = len(data)
|
|
# The index table sits right after the header, before the key table.
|
|
index_start = key_table - count * 16
|
|
if index_start < 20:
|
|
index_start = 20
|
|
if index_start + count * 16 > size:
|
|
return out
|
|
for i in range(count):
|
|
off = index_start + i * 16
|
|
key_off, fmt, length, _max_len, data_off = struct.unpack_from("<HHIII", data, off)
|
|
if key_table + key_off >= size:
|
|
continue
|
|
key = data[key_table + key_off:].split(b"\x00", 1)[0].decode("utf-8", "replace")
|
|
value_off = data_table + data_off
|
|
if value_off + length > size:
|
|
continue
|
|
raw = data[value_off:value_off + length]
|
|
if fmt in (0x0004, 0x0404) and length == 4: # uint32
|
|
out[key] = struct.unpack_from("<I", data, value_off)[0]
|
|
else: # UTF-8 string
|
|
out[key] = raw.split(b"\x00", 1)[0].decode("utf-8", "replace").strip()
|
|
return out
|
|
|
|
|
|
def _pbp_title(pbp_path):
|
|
"""Return the TITLE from an EBOOT.PBP's PARAM.SFO, or None."""
|
|
try:
|
|
sfo = _read_pbp_param_sfo(pbp_path)
|
|
if sfo:
|
|
return _sfo_strings(sfo).get("TITLE") or None
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
return None
|
|
|
|
|
|
def _homebrew_items(root):
|
|
"""PSP/GAME/**/EBOOT.PBP (homebrew, PSN, and Category Lite folders).
|
|
|
|
Category Lite nests homebrew one level deeper
|
|
(e.g. PSP/GAME/CAT_Homebrew/App/EBOOT.PBP), so search recursively. The
|
|
display name is the title from the PBP's embedded PARAM.SFO when available,
|
|
falling back to the folder name."""
|
|
items = []
|
|
game_dir = root / "PSP" / "GAME"
|
|
if game_dir.is_dir():
|
|
for eboot in sorted(game_dir.rglob("EBOOT.PBP")):
|
|
if not eboot.is_file():
|
|
continue
|
|
try:
|
|
st = eboot.stat()
|
|
size = st.st_size
|
|
mtime = int(st.st_mtime)
|
|
except OSError:
|
|
size = 0
|
|
mtime = 0
|
|
name = _pbp_title(eboot) or eboot.parent.name
|
|
items.append({"name": name, "id": eboot.parent.name, "path": str(eboot), "size": size, "mtime": mtime, "kind": "homebrew"})
|
|
return items
|
|
|
|
|
|
def _pkg_items(root):
|
|
""".pkg installer files at the root of the stick (CFW package installers)."""
|
|
items = []
|
|
try:
|
|
entries = list(root.iterdir())
|
|
except OSError:
|
|
return items
|
|
for path in sorted(entries):
|
|
if path.is_file() and path.suffix.lower() == ".pkg":
|
|
items.append(_file_item(path, "pkg"))
|
|
return items
|
|
|
|
|
|
def _theme_items(root):
|
|
"""Installed themes (``PSP/THEME/*.ptf``), named from the PTF header."""
|
|
items = []
|
|
theme_dir = root / "PSP" / "THEME"
|
|
if theme_dir.is_dir():
|
|
try:
|
|
entries = sorted(theme_dir.iterdir())
|
|
except OSError:
|
|
return items
|
|
for path in entries:
|
|
if not path.is_file() or path.suffix.lower() != ".ptf":
|
|
continue
|
|
item = _file_item(path, "theme")
|
|
try:
|
|
from . import theme as theme_mod
|
|
name = theme_mod.ptf_name(path)
|
|
except Exception: # noqa: BLE001
|
|
name = ""
|
|
if name:
|
|
item["name"] = name
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def _dlc_items(root):
|
|
"""Installed DLC (``PSP/GAME/<GAME_ID>/*.edat``)."""
|
|
items = []
|
|
game_dir = root / "PSP" / "GAME"
|
|
if game_dir.is_dir():
|
|
for path in sorted(game_dir.rglob("*")):
|
|
if not path.is_file() or path.suffix.lower() != ".edat":
|
|
continue
|
|
if path.name.upper() == "ISO.BIN.EDAT":
|
|
continue # package wrapper, not DLC content
|
|
item = _file_item(path, "dlc")
|
|
item["id"] = path.parent.name
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def list_installed(root):
|
|
"""Return ``{games, videos, homebrew, pkg, themes, dlc}`` for a PSP root."""
|
|
root = Path(root)
|
|
games, videos, homebrew_isos = _iso_items(root)
|
|
return {
|
|
"games": games,
|
|
"videos": videos,
|
|
"homebrew": _homebrew_items(root) + homebrew_isos,
|
|
"pkg": _pkg_items(root),
|
|
"themes": _theme_items(root),
|
|
"dlc": _dlc_items(root),
|
|
}
|
|
|
|
|
|
def build_index(inventory):
|
|
"""Build a lookup index for matching library discs against the PSP.
|
|
|
|
Returns ``{"raw", "names", "homebrew_ids", "homebrew_names"}`` where
|
|
``raw`` holds lowercase ISO stems (exact filename match), ``names`` holds
|
|
fuzzy-normalized stems, and the homebrew keys index ``PSP/GAME/**/EBOOT.PBP``
|
|
(homebrew + PS1 Classics, including Category Lite subfolders) by folder id
|
|
and embedded title.
|
|
"""
|
|
raw, names = set(), set()
|
|
for group in ("games", "videos"):
|
|
for it in (inventory or {}).get(group, []):
|
|
stem = Path(it["name"]).stem
|
|
raw.add(stem.lower())
|
|
norm = matcher.normalize_name(stem)
|
|
if norm:
|
|
names.add(norm)
|
|
|
|
hb_ids, hb_names = set(), set()
|
|
for it in (inventory or {}).get("homebrew", []):
|
|
ident = str(it.get("id") or "").strip().lower()
|
|
if ident:
|
|
hb_ids.add(ident)
|
|
stem = Path(it.get("name") or "").stem.strip().lower()
|
|
if stem:
|
|
hb_names.add(stem)
|
|
norm = matcher.normalize_name(Path(it.get("name") or "").stem)
|
|
if norm:
|
|
hb_names.add(norm)
|
|
return {"raw": raw, "names": names,
|
|
"homebrew_ids": hb_ids, "homebrew_names": hb_names}
|
|
|
|
|
|
def find_umd_iso(root):
|
|
"""Return the path of the ``UMD9660.ISO`` at a mount root, or None."""
|
|
try:
|
|
entries = list(Path(root).iterdir())
|
|
except OSError:
|
|
return None
|
|
for path in entries:
|
|
if path.is_file() and path.name.lower() == "umd9660.iso":
|
|
return path
|
|
return None
|
|
|
|
|
|
def umd_mounts():
|
|
"""Return mounted UMD volumes (USB UMD mode).
|
|
|
|
In USB UMD mode the PSP exposes a single volume whose name is the disc's
|
|
catalogue number (e.g. ``ULUS-10268``) and whose root holds one ISO named
|
|
``UMD9660.ISO``."""
|
|
found, seen = [], set()
|
|
for base in _candidate_bases():
|
|
base_path = Path(base)
|
|
if not base_path.is_dir():
|
|
continue
|
|
try:
|
|
children = list(base_path.iterdir())
|
|
except OSError:
|
|
continue
|
|
for child in sorted(children):
|
|
if not child.is_dir():
|
|
continue
|
|
try:
|
|
key = str(child.resolve())
|
|
except OSError:
|
|
key = str(child)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
if find_umd_iso(child) is not None:
|
|
found.append(str(child))
|
|
return found
|
|
|
|
|
|
def block_device_for_mount(root):
|
|
"""Return the block device backing a mount point, or None."""
|
|
try:
|
|
proc = subprocess.run(
|
|
["findmnt", "-n", "-o", "SOURCE", "--target", str(root)],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if proc.returncode != 0:
|
|
return None
|
|
lines = [ln.strip() for ln in proc.stdout.splitlines() if ln.strip()]
|
|
return lines[0] if lines else None
|
|
|
|
|
|
def eject(root):
|
|
"""Unmount a mounted PSP stick so it is safe to unplug.
|
|
|
|
Returns ``(ok, message)``.
|
|
"""
|
|
root = Path(root)
|
|
device = block_device_for_mount(root)
|
|
if not device:
|
|
return False, f"No block device found for {root}"
|
|
try:
|
|
proc = subprocess.run(
|
|
["udisksctl", "unmount", "-b", device],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
except OSError as exc:
|
|
return False, f"udisksctl failed: {exc}"
|
|
if proc.returncode == 0:
|
|
return True, proc.stdout.strip() or f"Unmounted {root}"
|
|
err = (proc.stderr or proc.stdout or "").strip()
|
|
return False, err or f"Unmount failed (exit code {proc.returncode})"
|