diff --git a/README.md b/README.md index cbfad20..8f987d7 100644 --- a/README.md +++ b/README.md @@ -274,9 +274,14 @@ skipped — and you'll get a clear error if *everything* in the item is private. ### 4. NoPayStation The **NoPayStation** source lists PSN content from -[NoPayStation](https://nopaystation.com/) — **PSP games** (`PSP_GAMES.tsv`) and -**PS1 Classics** (`PSX_GAMES.tsv`), selectable from the content-type dropdown -next to the source selector. +[NoPayStation](https://nopaystation.com/), selectable from the content-type +dropdown next to the source selector: + +- **PSP Games** (`PSP_GAMES.tsv`) — includes **minis** (also listed separately + in the `Type` column), PC Engine and NeoGeo releases. +- **PS1 Classics** (`PSX_GAMES.tsv`) +- **PSP Themes** (`PSP_THEMES.tsv`) +- **PSP DLC** (`PSP_DLCS.tsv`) Requirements & behaviour: @@ -286,12 +291,17 @@ Requirements & behaviour: only, and only runs if Python decryption fails and Wine is present. 2. Set **NoPayStation output dir** in **Settings → Store** (default `$PSP_STATE_DIR/nps`). -3. Downloading a PSP game writes `PSP//EBOOT.PBP` (plus - `PSP-KEY.EDAT` / `CONTENT.DAT` when the package ships them); a PS1 Classic - writes `PSX//EBOOT.PBP` (+ `DOCUMENT.DAT` and `KEYS.BIN`). Both - then show up in the **Library** (PS1 Classics under **PS1**, PSP games - under **Games**) and can be copied to the PSP like any other item. -4. The TSV lists are cached in `data/nps/` and refreshed on each store scan +3. Downloads land in the NPS folder and appear in the **Library**: + - PSP game → `PSP//EBOOT.PBP` (`psp_kind="eboot"`). + - PS1 Classic → `PSX//EBOOT.PBP` (+ `DOCUMENT.DAT`, `KEYS.BIN`). + - Theme → `THEME//.ptf` (the packaged PTF is + EDAT-unwrapped to a raw theme file). + - DLC → `DLC//.edat` (installed as-is into the owning + game's folder). +4. Copying to the PSP puts games/PS1 under `/PSP/GAME/` (optionally nested in + a Category Lite folder), themes under `/PSP/THEME/`, and DLC under + `/PSP/GAME//`. +5. The TSV lists are cached in `data/nps/` and refreshed on each store scan (drop the `.tsv` files there manually to work offline). > **DRM note:** NPS content is PSN-DRM'd. To *run* it on the PSP you need the diff --git a/app/gui.py b/app/gui.py index 29685dd..da95730 100644 --- a/app/gui.py +++ b/app/gui.py @@ -1681,7 +1681,7 @@ class StorePage(QWidget): self.source_combo.currentIndexChanged.connect(self._on_source_changed) bar.addWidget(self.source_combo) self.nps_type = QComboBox() - self.nps_type.addItems(["PSP Games", "PS1 Classics"]) + self.nps_type.addItems(["PSP Games", "PS1 Classics", "PSP Themes", "PSP DLC"]) self.nps_type.setVisible(False) self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed) bar.addWidget(self.nps_type) @@ -1788,7 +1788,7 @@ class StorePage(QWidget): if self.source_combo.currentIndex() == 1: return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user) if self.source_combo.currentIndex() == 3: - ct = "psp" if self.nps_type.currentIndex() == 0 else "psx" + ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()] return transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip) return transfers.LocalShareSource(self._store_dir) @@ -3266,11 +3266,13 @@ def _ps1_library_items(ps1_dir, output_dir, overrides, dates): def _nps_library_items(nps_dir, overrides, dates): - """NoPayStation-downloaded PSP games and PS1 Classics, merged into the library. + """NoPayStation-downloaded content, merged into the library. - Scans ``/PSP/*/EBOOT.PBP`` (PSP PSN games) and ``/PSX/*/EBOOT.PBP`` - (PS1 Classics). PSP games are ``system="games"`` with ``psp_kind="eboot"``; - PSX games are ``system="ps1"`` with ``ps1_kind="eboot"``. + Scans ``/PSP/*/EBOOT.PBP`` (PSP PSN games), ``/PSX/*/EBOOT.PBP`` + (PS1 Classics), ``/THEME/*/*.ptf`` (themes) and ``/DLC/*/*.edat`` + (DLC). PSP games are ``system="games"`` with ``psp_kind="eboot"``; PSX games + are ``system="ps1"`` with ``ps1_kind="eboot"``; themes/DLC use + ``psp_kind="theme"/"dlc"``. """ if not nps_dir: return [] @@ -3308,9 +3310,43 @@ def _nps_library_items(nps_dir, overrides, dates): else: item["psp_kind"] = "eboot" out.append(item) + + # Themes: /THEME//.ptf (extension case varies) + theme_dir = root / "THEME" + if theme_dir.is_dir(): + for ptf in sorted(theme_dir.rglob("*")): + if ptf.is_file() and ptf.suffix.lower() == ".ptf": + out.append(_nps_extra_item(ptf, "theme", ptf.stem)) + + # DLC: /DLC//.edat + dlc_dir = root / "DLC" + if dlc_dir.is_dir(): + for edat in sorted(dlc_dir.rglob("*")): + if edat.is_file() and edat.suffix.lower() == ".edat": + out.append(_nps_extra_item(edat, "dlc", edat.stem)) return out +def _nps_extra_item(path, psp_kind, name): + try: + size = path.stat().st_size + except OSError: + size = 0 + rid = covers.stable_key(name) + return { + "name": name, + "release_id": rid, + "system": psp_kind, + "canonical": name, + "serial": "", + "region": None, + "release_date": "", + "path": str(path), + "size": size, + "psp_kind": psp_kind, + } + + class LibraryWorker(QObject): result = Signal(list) finished = Signal() @@ -3957,7 +3993,7 @@ class MainWindow(QMainWindow): self.library_search.textChanged.connect(self._apply_library_filter) bar.addWidget(self.library_search, 1) self.library_type = QComboBox() - self.library_type.addItems(["All types", "Games", "Videos", "PS1"]) + self.library_type.addItems(["All types", "Games", "Videos", "PS1", "Themes", "DLC"]) self.library_type.currentTextChanged.connect(self._apply_library_filter) bar.addWidget(self.library_type) self.library_psp = QComboBox() @@ -4227,6 +4263,10 @@ class MainWindow(QMainWindow): continue if typ == "PS1" and it["system"] != "ps1": continue + if typ == "Themes" and it["system"] != "theme": + continue + if typ == "DLC" and it["system"] != "dlc": + continue if q and q not in (it["canonical"] or it["name"]).lower(): continue if psp_f == "On PSP" and not it.get("on_psp"): @@ -4252,7 +4292,8 @@ class MainWindow(QMainWindow): title.setIcon(self._psp_dot_icon()) title.setToolTip("Installed on PSP") self.library_table.setItem(row, 0, title) - kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1"}.get(it["system"], "Game") + kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1", + "theme": "Theme", "dlc": "DLC"}.get(it["system"], "Game") self.library_table.setItem(row, 1, QTableWidgetItem(kind_label)) self.library_table.setItem(row, 2, QTableWidgetItem(it.get("region") or "")) self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or "")) @@ -4410,7 +4451,7 @@ class MainWindow(QMainWindow): if not self._nps_dir: QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.") return - sub = "PSP" if src.content_type == "psp" else "PSX" + sub = {"psp": "PSP", "psx": "PSX", "theme": "THEME", "dlc": "DLC"}.get(src.content_type, "PSP") dest_dir = Path(self._nps_dir) / sub kind = "nps-download" else: @@ -4457,6 +4498,13 @@ class MainWindow(QMainWindow): self._maybe_auto_sync() def _on_psp(self, item): + psp_kind = item.get("psp_kind") + if psp_kind in ("theme", "dlc") and self._psp_root: + name = Path(item.get("path") or "").name + if psp_kind == "theme": + return (Path(self._psp_root) / "PSP" / "THEME" / name).exists() + game_id = Path(item.get("path") or "").parent.name + return (Path(self._psp_root) / "PSP" / "GAME" / game_id / name).exists() if not self._psp_index: return False stem = Path(item.get("canonical") or item.get("name") or "").stem @@ -4508,8 +4556,8 @@ class MainWindow(QMainWindow): return # UMD Video discs can't be played in PPSSPP if item.get("system") == "ps1" and item.get("ps1_kind") != "eboot": return # raw PS1 discs are converted, not played - if item.get("psp_kind") == "eboot": - return # encrypted PSP PSN EBOOTs can't run in PPSSPP + if item.get("psp_kind") in ("eboot", "theme", "dlc"): + return # encrypted PSN EBOOTs/themes/DLC can't run in PPSSPP path = item.get("path") if not path or not Path(path).exists(): QMessageBox.warning(self, "Missing file", f"File not found:\n{path}") @@ -4582,6 +4630,23 @@ class MainWindow(QMainWindow): psp_root = Path(self._psp_root) if system == "videos": dest_dir = psp_root / "ISO" / "VIDEO" + elif item.get("psp_kind") == "theme": + # Themes install as a single .ptf under /PSP/THEME. + dest_name = Path(item["path"]).name + dest_dir = psp_root / "PSP" / "THEME" + src = transfers.FileCopySource(item["path"]) + log.info("copying theme to psp: %s -> %s", item["path"], dest_dir / dest_name) + self.queue.enqueue(src, dest_dir, {"dest_name": dest_name}, dest_name, kind="psp-install") + return True + elif item.get("psp_kind") == "dlc": + # DLC installs as a single .edat into /PSP/GAME/. + game_id = Path(item["path"]).parent.name + dest_name = Path(item["path"]).name + dest_dir = psp_root / "PSP" / "GAME" / game_id + src = transfers.FileCopySource(item["path"]) + log.info("copying dlc to psp: %s -> %s", item["path"], dest_dir / dest_name) + self.queue.enqueue(src, dest_dir, {"dest_name": dest_name}, dest_name, kind="psp-install") + return True elif system == "ps1" or item.get("psp_kind") == "eboot": # PS1 EBOOTs and NoPayStation PSP games install as a whole game # folder under /PSP/GAME (optionally nested in a Category Lite @@ -4674,6 +4739,25 @@ class MainWindow(QMainWindow): QMessageBox.information(self, "Not on PSP", "That disc is not installed on the PSP.") return stem = Path(item.get("canonical") or item.get("name") or "").stem + psp_kind = item.get("psp_kind") + if psp_kind in ("theme", "dlc"): + name = Path(item.get("path") or "").name + target = (Path(self._psp_root) / "PSP" / "THEME" / name) if psp_kind == "theme" \ + else (Path(self._psp_root) / "PSP" / "GAME" / Path(item.get("path") or "").parent.name / name) + if not target.exists(): + QMessageBox.warning(self, "Not found", f"Could not find the installed copy on the PSP:\n{name}") + return + res = QMessageBox.question( + self, "Delete from PSP", + f"Delete this from the PSP?\n\n{name}\n\n{target}", + QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel, + ) + if res != QMessageBox.Yes: + return + if not transfers.remove_path(target): + QMessageBox.warning(self, "Delete incomplete", f"Could not delete:\n{target}") + self._refresh_psp() + return if item.get("system") == "ps1" or item.get("psp_kind") == "eboot": # PS1 EBOOTs / NoPayStation PSP games: delete the whole game folder, # matched by the source folder id (or title). diff --git a/app/nopaystation.py b/app/nopaystation.py index 5dca267..b5ceb66 100644 --- a/app/nopaystation.py +++ b/app/nopaystation.py @@ -22,7 +22,12 @@ from . import config log = logging.getLogger("nopaystation") -_TSV_NAMES = {"psp": "PSP_GAMES.tsv", "psx": "PSX_GAMES.tsv"} +_TSV_NAMES = { + "psp": "PSP_GAMES.tsv", + "psx": "PSX_GAMES.tsv", + "theme": "PSP_THEMES.tsv", + "dlc": "PSP_DLCS.tsv", +} # NoPayStation TSVs are tab-separated with a header row whose exact columns # differ per platform (PSP has "Type"/"RAP" columns, PSX has "Original Name"). @@ -45,7 +50,7 @@ _HEADER_ALIASES = { def tsv_url(content_type: str, base: str = "") -> str: - """TSV URL for a content type (``psp`` / ``psx``).""" + """TSV URL for a content type (``psp`` / ``psx`` / ``theme`` / ``dlc``).""" base = (base or config.NPS_TSV_BASE).rstrip("/") return f"{base}/{_TSV_NAMES.get(content_type, _TSV_NAMES['psp'])}" @@ -76,7 +81,7 @@ def parse_tsv(text: str, content_type: str) -> List[dict]: ``release_date``, ``mtime_ns``) plus ``title_id``, ``pkg_url``, ``sha256``, ``content_type`` and the cover ``system`` ("games" for PSP, "ps1" for PSX). """ - system = "games" if content_type == "psp" else "ps1" + system = {"psp": "games", "psx": "ps1", "theme": "theme", "dlc": "dlc"}.get(content_type, "games") items: List[dict] = [] header = None for line in text.splitlines(): @@ -187,7 +192,7 @@ def _ensure_keys_bin(folder, kind): def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None, - progress_cb=None, cancel_event=None) -> Path: + kind_hint: str = None, progress_cb=None, cancel_event=None) -> Path: """Decrypt/extract a ``.pkg`` into ``dest_dir//``. Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls @@ -199,12 +204,13 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None, pkg_path = Path(pkg_path) dest = Path(dest_dir) / _safe_folder(title_id) - if (dest / "EBOOT.PBP").exists(): + if dest.is_dir() and any(dest.iterdir()): return dest # 1. Pure-Python decryptor (primary). try: - _kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, progress_cb, cancel_event) + _kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint, + progress_cb, cancel_event) _ensure_keys_bin(folder, _kind) return folder except TransferCancelled: diff --git a/app/pkg.py b/app/pkg.py index 2fdfe46..19b0116 100644 --- a/app/pkg.py +++ b/app/pkg.py @@ -1,10 +1,19 @@ -"""Pure-Python PSP/PSX PSN ``.pkg`` decryption (no Wine / pkg2zip needed). +"""Pure-Python PSP/PSX/theme/DLC PSN ``.pkg`` decryption (no Wine / pkg2zip). Ports the folder-mode extraction used by pkgi-psp's depackager and pkg2zip: -parse the ``\\x7FPKG`` header, derive the PSP AES key, AES-CTR-decrypt the file -entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder. The -resulting ``EBOOT.PBP`` keeps its DATA.PSP EDAT-encrypted, so it runs on CFW -with the ``npdrm_free`` plugin (ARK ships it) — same as pkgi-psp's install. +parse the ``\\x7FPKG`` header, derive the PSP/PS3 AES key, AES-CTR-decrypt the +file entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder. + +Supported content: + +* PSP games / minis (``EBOOT.PBP``) — ``DATA.PSP`` stays EDAT-encrypted, so it + runs on CFW with ``npdrm_free`` (ARK ships it), same as pkgi-psp's install. +* PS1 Classics (``EBOOT.PBP`` + ``DOCUMENT.DAT``). +* PSP themes (``.ptf``) — the packaged PTF is EDAT-wrapped and is unwrapped here. +* PSP DLC (``.edat``) — extracted as-is into the owning game's folder. + +Theme unwrapping needs ``pycryptodome`` (for AES-CMAC + ECB); everything else +works with either pycryptodome or the built-in pure-Python AES fallback. """ from __future__ import annotations @@ -16,6 +25,14 @@ PKG_MAGIC = 0x7F504B47 PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b") PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8") +# PSP KIRK/AMCTL keys used to unwrap theme PTF EDATs (pkgi-psp / libkirk). +_KIRK38 = bytes.fromhex("12468d7e1c42209bba5426835eb03303") +_KIRK39 = bytes.fromhex("c43bb6d653ee67493ea95fbc0ced6f8a") +_KIRK63 = bytes.fromhex("9c9b1372f8c640cf1c62f5d592ddb582") +_AMCTL3 = bytes.fromhex("e350ed1d910a1fd029bb1c3ef34077fb") +_AMCTL4 = bytes.fromhex("135fa47cab395ba476b8cca98f3a0445") +_AMCTL5 = bytes.fromhex("678d7fa32a9ca0d1508ad8385e4b017e") + try: from Crypto.Cipher import AES as _AES # pycryptodome (fast) _HAVE_CRYPTO = True @@ -136,13 +153,86 @@ def _make_ctr_decryptor(key, iv, block_index): return decrypt +# --------------------------------------------------------------------------- # +# PSP theme PTF EDAT unwrapping (pkgi-psp's unpack_psp_edat) +# --------------------------------------------------------------------------- # +def _need_crypto(): + if not _HAVE_CRYPTO: + raise RuntimeError("theme extraction needs pycryptodome (pip install pycryptodome)") + + +def _ecb_decrypt(key, block): + _need_crypto() + return _AES.new(key, _AES.MODE_ECB).decrypt(block) + + +def _cmac(key, data): + _need_crypto() + from Crypto.Hash import CMAC + c = CMAC.new(key, ciphermod=_AES) + c.update(data) + return c.digest() + + +def _kirk_iv(mac, key_header, offset2): + tmp = _ecb_decrypt(_KIRK38, bytes(key_header[0x70:0x80])) + iv = bytes(mac[i] ^ tmp[i] ^ key_header[offset2 + i] ^ _AMCTL3[i] ^ _AMCTL5[i] for i in range(16)) + iv = _ecb_decrypt(_KIRK39, iv) + return bytes(b ^ _AMCTL4[i] for i, b in enumerate(iv)) + + +def _kirk_cbc_decrypt(iv, index, data): + prev = bytearray(16) if index == 0 else bytearray(iv[:12]) + index.to_bytes(4, "little") + block = bytearray(iv[:12]) + index.to_bytes(4, "little") + out = bytearray() + for i in range(0, len(data), 16): + v = int.from_bytes(block[12:16], "little") + 1 + block[12:16] = v.to_bytes(4, "little") + o = _ecb_decrypt(_KIRK63, bytes(block)) + out += bytes(b ^ prev[k] ^ o[k] for k, b in enumerate(data[i:i + 16])) + prev = bytes(block) + return bytes(out) + + +def _unwrap_theme_ptf(buf): + """Unwrap a PSP theme PTF EDAT (``buf`` is the fully CTR-decrypted entry).""" + if len(buf) < 0x90 + 0xA0: + raise RuntimeError("theme EDAT is too short") + kh_off = buf[0xC] + key_header = bytearray(buf[kh_off:kh_off + 0xA0]) + if bytes(key_header[:4]) != b"\x00PGD": + raise RuntimeError("theme EDAT: bad PGD header") + if int.from_bytes(key_header[4:8], "little") != 1 or int.from_bytes(key_header[8:12], "little") != 1: + raise RuntimeError("theme EDAT: unsupported key/drm type") + + mac = _cmac(_KIRK38, bytes(key_header[:0x70])) + + # Step 1: reveal the data size/offset fields (bytes 0x30..0x60). + key_header[0x30:0x60] = _kirk_cbc_decrypt(_kirk_iv(mac, key_header, 0x10), 0, bytes(key_header[0x30:0x60])) + data_size = int.from_bytes(key_header[0x44:0x48], "little") + data_offset = int.from_bytes(key_header[0x4c:0x50], "little") + if data_offset != 0x90: + raise RuntimeError("theme EDAT: unexpected data offset") + + # Step 2: decrypt the PTF body. + iv = _kirk_iv(mac, key_header, 0x30) + out = bytearray() + nblocks = (data_size + 15) // 16 + for i in range(nblocks): + chunk = buf[kh_off + data_offset + i * 16:kh_off + data_offset + (i + 1) * 16] + out += _kirk_cbc_decrypt(iv, i, chunk) + return bytes(out[:data_size]) + + # --------------------------------------------------------------------------- # # PKG extraction # --------------------------------------------------------------------------- # -def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None): - """Decrypt a PSP/PSX PSN pkg into ``dest_dir//``. +def extract(pkg_path, dest_dir, kind_hint=None, progress_cb=None, cancel_event=None): + """Decrypt a PSN pkg into ``dest_dir//``. - Returns ``(kind, title_id, folder)`` where kind is ``"psp"`` or ``"psx"``. + Returns ``(kind, title_id, folder)`` where kind is ``"psp"``, ``"psx"``, + ``"theme"`` or ``"dlc"``. ``kind_hint`` ("theme"/"dlc") overrides the + pkg-header content-type detection (NoPayStation DLC ships as two formats). """ from .store import TransferCancelled @@ -179,19 +269,30 @@ def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None): items_offset = int.from_bytes(m[8:12], "big") off += 8 + msize - if content_type == 6: + if kind_hint in ("theme", "dlc"): + kind = kind_hint + elif content_type == 6: kind = "psx" elif content_type in (7, 0xE, 0xF, 0x10): kind = "psp" + elif content_type == 9: + kind = "theme" + elif content_type == 0xB: + kind = "dlc" else: raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}") - if key_type != 1: + # NoPayStation DLC is wrapped in a PS3-format pkg (key type 4 in the header + # but actually encrypted with the PS3 key); everything else uses the PSP key. + if content_type == 0xB: + main_key = PS3_KEY + ps3_key = PS3_KEY + elif key_type == 1: + main_key = PSP_KEY + ps3_key = PS3_KEY + else: raise RuntimeError(f"unsupported PKG key type {key_type}") - main_key = PSP_KEY - ps3_key = PS3_KEY - # Decrypt the whole item table in one shot. with open(pkg_path, "rb") as fh: fh.seek(enc_offset + items_offset) @@ -232,26 +333,42 @@ def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None): continue if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"): continue + if kind == "theme" and not base.lower().endswith(".ptf"): + continue + if kind == "dlc" and not base.lower().endswith(".edat"): + continue - dest = folder / base - decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16) - fh.seek(enc_offset + data_offset) - with open(dest, "wb") as out: - remaining = data_size - while remaining > 0: - if cancel_event is not None and cancel_event.is_set(): - raise TransferCancelled() - chunk = fh.read(min(1 << 20, remaining)) - if not chunk: - break - out.write(decrypt(chunk)) - remaining -= len(chunk) - done += len(chunk) - if progress_cb is not None: - progress_cb("Extracting", min(done / total, 1.0), "") + if kind == "theme": + # Read the (small) entry fully, CTR-decrypt, then unwrap the EDAT. + fh.seek(enc_offset + data_offset) + raw = _ctr_decrypt(item_key, iv, data_offset // 16, fh.read(data_size)) + dest = folder / base + dest.write_bytes(_unwrap_theme_ptf(raw)) + done += data_size + else: + dest = folder / base + decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16) + fh.seek(enc_offset + data_offset) + with open(dest, "wb") as out: + remaining = data_size + while remaining > 0: + if cancel_event is not None and cancel_event.is_set(): + raise TransferCancelled() + chunk = fh.read(min(1 << 20, remaining)) + if not chunk: + break + out.write(decrypt(chunk)) + remaining -= len(chunk) + done += len(chunk) + if progress_cb is not None: + progress_cb("Extracting", min(done / total, 1.0), "") written.append(dest) - if not (folder / "EBOOT.PBP").exists(): + if kind in ("psp", "psx") and not (folder / "EBOOT.PBP").exists(): raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}") + if kind == "theme" and not any(p.suffix.lower() == ".ptf" for p in written): + raise RuntimeError(f"no PTF found in {pkg_path.name}") + if kind == "dlc" and not written: + raise RuntimeError(f"no DLC EDAT found in {pkg_path.name}") return kind, title_id, folder diff --git a/app/transfers.py b/app/transfers.py index 4740b59..3700485 100644 --- a/app/transfers.py +++ b/app/transfers.py @@ -468,9 +468,10 @@ class LocalShareSource(Source): class NoPayStationSource(Source): """Browse NoPayStation .pkg lists and install PSP/PSX PSN content. - ``content_type`` is ``"psp"`` (PSP_GAMES.tsv) or ``"psx"`` (PSX_GAMES.tsv). - Downloading decrypts the ``.pkg`` with the bundled pure-Python decryptor - and writes a ``/EBOOT.PBP`` game folder into ``dest_dir``. + ``content_type`` is ``"psp"`` (PSP_GAMES.tsv), ``"psx"`` (PSX_GAMES.tsv), + ``"theme"`` (PSP_THEMES.tsv) or ``"dlc"`` (PSP_DLCS.tsv). Downloading + decrypts the ``.pkg`` with the bundled pure-Python decryptor and writes a + ``/`` game folder into ``dest_dir``. """ label = "NoPayStation" @@ -490,8 +491,9 @@ class NoPayStationSource(Source): dest_dir = Path(dest_dir) dest_dir.mkdir(parents=True, exist_ok=True) - # The bundled Python decryptor handles PSP/PSX directly; pkg2zip is - # only an optional fallback if a binary happens to be configured. + # The bundled Python decryptor handles PSP/PSX/themes/DLC directly; + # pkg2zip is only an optional fallback if a binary happens to be + # configured (and only supports games/PSX). pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip) title_id = item.get("title_id") or item.get("content_id") or "" pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg" @@ -501,8 +503,9 @@ class NoPayStationSource(Source): if progress_cb is not None: progress_cb("Downloading", 0.0, "") download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event) + kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None folder = nopaystation.extract_pkg(pkg, title_id, dest_dir, pkg2zip, - progress_cb, cancel_event) + kind_hint, progress_cb, cancel_event) return folder finally: shutil.rmtree(tmp, ignore_errors=True)