"""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(" size: return out for i in range(count): off = index_start + i * 16 key_off, fmt, length, _max_len, data_off = struct.unpack_from("= 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("