diff --git a/README.md b/README.md index 982e1fa..cd8a974 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PSP Preservation Tool the name is terrible, i know. - + ![AI Generated](https://raw.githubusercontent.com/dotnetdave/ai-usage-badges/refs/heads/main/badges/png/1x/ai-generated.png) Native Linux GUI (PySide6) for preserving PSP UMD **games** and **videos** as @@ -18,9 +18,9 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew. - **Library** — scan folders of `.iso` discs, verify against the DAT catalog, browse with cover art, search/filter, see per-game details (developer, publisher, genre, release date, synopsis, trailer), and launch discs in PPSSPP. -- **Store** — browse and download discs from three sources: a local 7z folder, - your own HTTP store server, or an archive.org item (see - [Store](#store--archiveorg-and-local-folder)). +- **Store** — browse and download discs from four sources: a local 7z folder, + your own HTTP store server, an archive.org item, or NoPayStation (PSP/PS1 + PSN `.pkg` content) (see [Store](#store--archiveorg-and-local-folder)). - **Queue** — background transfers with progress, cancel, per-row delete, etc. - **PSP Sync** — detect a connected PSP (USB) or a mounted UMD, inventory what's installed (games / UMD videos / homebrew / PKG), copy discs to the PSP, delete @@ -38,6 +38,8 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew. - Linux + Python 3 - `PySide6` — the only Python dependency (`pip install -r requirements.txt`) - `7z` (p7zip) — used to peek/extract disc images from store archives +- `pkg2zip` (optional) — decrypts NoPayStation `.pkg` files (see + [NoPayStation](#4-nopaystation)) - PPSSPP (optional) — to launch discs from the details panel ## Install @@ -132,6 +134,9 @@ QSettings under `PSPPreserve`) taking precedence where noted. | `PSP_ARCHIVE_ITEM` | `psp-redump-roms321.com` | archive.org item identifier | | `PSP_ARCHIVE_LOGGED_IN_SIG` | *(empty)* | archive.org `logged-in-sig` cookie | | `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie | +| `PSP_NPS_DIR` | `/nps` | NoPayStation output folder | +| `PSP_NPS_TSV_BASE` | `https://nopaystation.com/tsv/` | NoPayStation TSV base URL | +| `PSP_PKG2ZIP_PATH` | *(empty)* | pkg2zip binary path (else auto-detected) | | `PSP_API_TOKEN` | *(empty)* | server API bearer token (unset = open) | | `PSP_CREDENTIALS` | `$PSP_DATA_DIR/credentials.json` | credentials file | | `PSP_LIBRETRO_COVERS_URL` | *(empty)* | direct URL for `libretro-covers.zip` (else derived from store server) | @@ -258,6 +263,32 @@ If the item's files are marked **private** (login required), set the from a logged-in archive.org browser session). Without them, private files are 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. + +Requirements & behaviour: + +1. Install [`pkg2zip`](https://github.com/mmozeiko/pkg2zip) (the same tool the + pkgi family uses) and either put it on `PATH` or set its path in + **Settings → Store**. NoPayStation `.pkg` files are encrypted PSN packages, + not ZIPs — `pkg2zip` decrypts them. +2. Set **NoPayStation output dir** in **Settings → Store** (default + `$PSP_STATE_DIR/nps`). +3. Downloading a PSP game writes `PSP//EBOOT.PBP`; a PS1 Classic + writes `PSX//EBOOT.PBP`. 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 the state dir and refreshed on each store scan. + +> **DRM note:** NPS content is PSN-DRM'd. To *run* it on the PSP you need the +> [`npdrm_free`](https://github.com/qwikrazor87/npdrm_free) plugin (ARK and most +> CFW already ship it). The desktop app only downloads and decrypts; it doesn't +> strip DRM. + ## Server setup The catalog + store API is a single-file, stdlib-only HTTP server diff --git a/app/config.py b/app/config.py index 238a04c..850297e 100644 --- a/app/config.py +++ b/app/config.py @@ -46,6 +46,14 @@ ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com") ARCHIVE_LOGGED_IN_SIG = os.environ.get("PSP_ARCHIVE_LOGGED_IN_SIG", "") ARCHIVE_LOGGED_IN_USER = os.environ.get("PSP_ARCHIVE_LOGGED_IN_USER", "") +# NoPayStation: where extracted .pkg content lands, and the TSV source base. +# PSP games go to /PSP//; PS1 Classics to .../PSX/. +NPS_OUTPUT_DIR = Path(os.environ.get("PSP_NPS_DIR", str(STATE_DIR / "nps"))) +NPS_TSV_BASE = os.environ.get("PSP_NPS_TSV_BASE", "https://nopaystation.com/tsv/") + +# pkg2zip binary used to decrypt/extract NPS .pkg files (blank = auto-detect). +PKG2ZIP_PATH = os.environ.get("PSP_PKG2ZIP_PATH", "") + # Scan roots (used by the scanner phase). Comma-separated in env. GAMES_ROOTS = [Path(p) for p in os.environ.get("PSP_GAMES_ROOTS", "/data/umd_games").split(",") if p.strip()] VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()] diff --git a/app/gui.py b/app/gui.py index 2a81e5c..4aecf4a 100644 --- a/app/gui.py +++ b/app/gui.py @@ -682,7 +682,10 @@ class GameDetailsPanel(QWidget): self.system = item.get("system") self._ps1_kind = item.get("ps1_kind") path = item.get("path") or "" - self.filename.setText(Path(path).name if path else "") + if (is_ps1 and item.get("ps1_kind") == "eboot") or item.get("psp_kind") == "eboot": + self.filename.setText(Path(path).parent.name if path else "") + else: + self.filename.setText(Path(path).name if path else "") self.filename.setToolTip(path) self.filename.setVisible(bool(path)) is_ps1 = item.get("system") == "ps1" @@ -1072,7 +1075,8 @@ class SettingsPage(QWidget): def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe, store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user, - iso_category, ps1_category, ps1_dir, ps1_output_dir, ps1_base_pbp, parent=None): + iso_category, ps1_category, ps1_dir, ps1_output_dir, ps1_base_pbp, + nps_dir="", pkg2zip_path="", parent=None): super().__init__(parent) outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) @@ -1107,6 +1111,8 @@ class SettingsPage(QWidget): self.archive_sig_edit = self._secret_row(form, "Archive.org logged-in-sig:", store_archive_sig) self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user:", store_archive_user) self.store_edit = self._dir_row(form, "Local store dir:", store_dir) + self.nps_edit = self._dir_row(form, "NoPayStation output dir:", nps_dir) + self.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional):", pkg2zip_path) self.tabs.addTab(page, "Store") # -- Saves ------------------------------------------------------- # @@ -1264,6 +1270,12 @@ class SettingsPage(QWidget): def archive_user(self): return self.archive_user_edit.text().strip() + def nps_dir(self): + return self.nps_edit.text().strip() + + def pkg2zip_path(self): + return self.pkg2zip_edit.text().strip() + def ppsspp_saves_dir(self): return self.ppsspp_edit.text().strip() @@ -1628,6 +1640,8 @@ class StorePage(QWidget): self._archive_id = "" self._archive_sig = "" self._archive_user = "" + self._nps_dir = "" + self._pkg2zip = "" self._items = [] self._filtered = [] self._lib_norm = [] @@ -1657,9 +1671,14 @@ class StorePage(QWidget): self.search.textChanged.connect(self._schedule_filter) bar.addWidget(self.search, 1) self.source_combo = QComboBox() - self.source_combo.addItems(["Server", "Archive.org", "Local folder"]) + self.source_combo.addItems(["Server", "Archive.org", "Local folder", "NoPayStation"]) 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.setVisible(False) + self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed) + bar.addWidget(self.nps_type) self.view_combo = QComboBox() self.view_combo.addItems(["Cover view", "List view"]) bar.addWidget(self.view_combo) @@ -1738,13 +1757,16 @@ class StorePage(QWidget): self.status = QLabel("") layout.addWidget(self.status) - def set_store_config(self, local_dir, server_url, token, archive_id="", archive_sig="", archive_user=""): + def set_store_config(self, local_dir, server_url, token, archive_id="", archive_sig="", archive_user="", + nps_dir="", pkg2zip_path=""): self._store_dir = local_dir or "" self._server_url = server_url or "" self._token = token or "" self._archive_id = archive_id or "" self._archive_sig = archive_sig or "" self._archive_user = archive_user or "" + self._nps_dir = nps_dir or "" + self._pkg2zip = pkg2zip_path or "" if self._server_url: self.status.setText(f"Store: {self._server_url}") elif self._archive_id: @@ -1759,9 +1781,20 @@ class StorePage(QWidget): return transfers.RemoteStoreSource(self._server_url, self._token) 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" + return transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip) return transfers.LocalShareSource(self._store_dir) + def _on_nps_type_changed(self, _idx): + if self.source_combo.currentIndex() == 3: + self._items = [] + self._filtered = [] + self._dirty = False + self.refresh() + def _on_source_changed(self, _idx): + self.nps_type.setVisible(self.source_combo.currentIndex() == 3) self._items = [] self._filtered = [] self._dirty = False @@ -1813,6 +1846,11 @@ class StorePage(QWidget): log.info("store: fetching from archive.org %s", self._archive_id) self.status.setText("Fetching store from archive.org…") worker = RemoteStoreScanWorker(transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)) + elif self.source_combo.currentIndex() == 3: # NoPayStation + ct = "psp" if self.nps_type.currentIndex() == 0 else "psx" + log.info("store: fetching NoPayStation %s list", ct) + self.status.setText(f"Fetching NoPayStation ({ct.upper()})…") + worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip)) else: if not self._store_dir: self.status.setText("Set the local store folder in Settings (Advanced)") @@ -1887,8 +1925,9 @@ class StorePage(QWidget): if lc is not None: rid, system = lc else: - rid, system = covers.stable_key(it["name"]), "games" - it["release_date"] = dates.get((str(rid), system)) or "" + rid, system = covers.stable_key(it["name"]), it.get("system") or "games" + if not it.get("release_date"): + it["release_date"] = dates.get((str(rid), system)) or "" filtered.append(it) filtered.sort(key=self._store_sort_key) self._filtered = filtered @@ -1975,7 +2014,7 @@ class StorePage(QWidget): if lc is not None: rid, system = lc else: - rid, system = covers.stable_key(it["name"]), "games" + rid, system = covers.stable_key(it["name"]), it.get("system") or "games" p = covers.cover_path(rid, system) if p.exists() and covers.file_is_image(p): item.setIcon(QIcon(_fit_pixmap(QPixmap(str(p)), self.ICON_W, self.ICON_H))) @@ -1998,7 +2037,7 @@ class StorePage(QWidget): if lc is not None: rid, system = lc else: - rid, system = covers.stable_key(it["name"]), "games" + rid, system = covers.stable_key(it["name"]), it.get("system") or "games" rel = it.get("release_date") or "" self.table.setItem(row, 4, QTableWidgetItem(rel)) if not rel and system == "games": @@ -2111,7 +2150,7 @@ class StorePage(QWidget): shown = getattr(self.details, "_item", None) if it is not None and shown is not None and it.get("path") == shown.get("path"): lc = it.get("_lib_cover") - rid, system = (lc if lc is not None else (covers.stable_key(it["name"]), "games")) + rid, system = (lc if lc is not None else (covers.stable_key(it["name"]), it.get("system") or "games")) self.details._set_cover(rid, system) def _selected_items(self): @@ -2141,7 +2180,7 @@ class StorePage(QWidget): if lc is not None: rid, system = lc else: - rid, system = covers.stable_key(it["name"]), "games" + rid, system = covers.stable_key(it["name"]), it.get("system") or "games" cached = info.load(str(rid), system) if cached is not None: self.details.set_metadata(cached) @@ -2172,7 +2211,7 @@ class StorePage(QWidget): if item is None: return lc = item.get("_lib_cover") - rid, system = lc if lc is not None else (covers.stable_key(item["name"]), "games") + rid, system = lc if lc is not None else (covers.stable_key(item["name"]), item.get("system") or "games") dlg = ScrapeDialog(item, self, rid=rid, system=system) if dlg.exec() != QDialog.Accepted: return @@ -2215,8 +2254,9 @@ class StorePage(QWidget): menu.exec(self.table.viewport().mapToGlobal(pos)) def _search_cover(self, it): + system = it.get("system") or "games" rid = covers.stable_key(it["name"]) - dlg = ScrapeDialog({"name": it["name"], "system": "games"}, self, rid=rid, system="games") + dlg = ScrapeDialog({"name": it["name"], "system": system}, self, rid=rid, system=system) if dlg.exec() == QDialog.Accepted: meta = dlg.selected_meta if meta: @@ -2250,9 +2290,7 @@ class StorePage(QWidget): if lc is not None: rid, system = lc else: - rid, system = covers.stable_key(it["name"]), "games" - if system != "games": - continue + rid, system = covers.stable_key(it["name"]), it.get("system") or "games" key = (str(rid), system) if key in seen: continue @@ -2345,6 +2383,7 @@ class QueuePage(QWidget): "ps1-convert": "PS1 convert", "umd-dump": "UMD dump", "save-sync": "Save sync", + "nps-download": "NoPayStation", } def __init__(self, parent=None): @@ -3095,7 +3134,7 @@ class PspSyncPage(QWidget): self._workers = [] -def build_library_items(ps1_dir="", output_dir=""): +def build_library_items(ps1_dir="", output_dir="", nps_dir=""): """Build the library item list from the DB with a single joined query.""" items = [] seen = set() @@ -3165,6 +3204,7 @@ def build_library_items(ps1_dir="", output_dir=""): finally: conn.close() items.extend(_ps1_library_items(ps1_dir, output_dir, overrides, dates)) + items.extend(_nps_library_items(nps_dir, overrides, dates)) return items @@ -3206,18 +3246,65 @@ def _ps1_library_items(ps1_dir, output_dir, overrides, dates): return out +def _nps_library_items(nps_dir, overrides, dates): + """NoPayStation-downloaded PSP games and PS1 Classics, 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"``. + """ + if not nps_dir: + return [] + root = Path(nps_dir) + out = [] + for sub, system in (("PSP", "games"), ("PSX", "ps1")): + d = root / sub + if not d.is_dir(): + continue + for eboot in sorted(d.rglob("EBOOT.PBP")): + if not eboot.is_file(): + continue + folder = eboot.parent + name = psp._pbp_title(eboot) or folder.name + rid = covers.stable_key(name) + try: + size = sum(p.stat().st_size for p in folder.rglob("*") if p.is_file()) + except OSError: + size = 0 + item = { + "name": name, + "release_id": rid, + "system": system, + "canonical": name, + "serial": "", + "region": None, + "title_override": overrides.get((system, str(rid))), + "release_date": dates.get((str(rid), system)) or "", + "path": str(eboot), + "size": size, + } + if system == "ps1": + item["ps1_kind"] = "eboot" + item["ps1_status"] = "converted" + else: + item["psp_kind"] = "eboot" + out.append(item) + return out + + class LibraryWorker(QObject): result = Signal(list) finished = Signal() - def __init__(self, ps1_dir="", output_dir=""): + def __init__(self, ps1_dir="", output_dir="", nps_dir=""): super().__init__() self.ps1_dir = ps1_dir self.output_dir = output_dir + self.nps_dir = nps_dir def run(self): try: - items = build_library_items(self.ps1_dir, self.output_dir) + items = build_library_items(self.ps1_dir, self.output_dir, self.nps_dir) except Exception: # noqa: BLE001 items = [] self.result.emit(items) @@ -3649,6 +3736,8 @@ class MainWindow(QMainWindow): self._ps1_dir = self._first_existing(config.PS1_ROOTS) or str(self.settings.value("ps1_dir", "") or "") self._ps1_output_dir = str(self.settings.value("ps1_output_dir", str(config.PS1_OUTPUT_DIR)) or "") self._ps1_base_pbp = str(self.settings.value("ps1_base_pbp", "") or "") + self._nps_dir = str(self.settings.value("nps_dir", str(config.NPS_OUTPUT_DIR)) or "") + self._pkg2zip = str(self.settings.value("pkg2zip_path", config.PKG2ZIP_PATH) or "") log.info("games dir: %s", self._games_dir or "(none)") log.info("videos dir: %s", self._videos_dir or "(none)") log.info("store: %s", self._store_server_url or self._store_dir or "(none)") @@ -3667,7 +3756,8 @@ class MainWindow(QMainWindow): self.tabs.addTab(self._build_library_tab(), "Library") self.store_page = StorePage() self.store_page.set_store_config(self._store_dir, self._store_server_url, self._store_token, - self._store_archive_id, self._store_archive_sig, self._store_archive_user) + self._store_archive_id, self._store_archive_sig, self._store_archive_user, + self._nps_dir, self._pkg2zip) self.store_page.download_requested.connect(self._on_store_download) self.tabs.addTab(self.store_page, "Store") self.queue_page = QueuePage() @@ -3858,7 +3948,8 @@ class MainWindow(QMainWindow): self._store_server_url, self._store_token, self._store_archive_id, self._store_archive_sig, self._store_archive_user, self._iso_category, self._ps1_category, - self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp) + self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp, + self._nps_dir, self._pkg2zip) self.settings_page.saved.connect(self._on_settings_saved) return self.settings_page @@ -3880,7 +3971,8 @@ class MainWindow(QMainWindow): self.settings.setValue("store_archive_sig", self._store_archive_sig) self.settings.setValue("store_archive_user", self._store_archive_user) self.store_page.set_store_config(self._store_dir, self._store_server_url, self._store_token, - self._store_archive_id, self._store_archive_sig, self._store_archive_user) + self._store_archive_id, self._store_archive_sig, self._store_archive_user, + self._nps_dir, self._pkg2zip) self.store_page.refresh() self._ppsspp_saves_dir = self.settings_page.ppsspp_saves_dir() self._cloud_saves_dir = self.settings_page.cloud_saves_dir() @@ -3891,9 +3983,13 @@ class MainWindow(QMainWindow): self._ps1_dir = self.settings_page.ps1_dir() self._ps1_output_dir = self.settings_page.ps1_output_dir() self._ps1_base_pbp = self.settings_page.ps1_base_pbp() + self._nps_dir = self.settings_page.nps_dir() + self._pkg2zip = self.settings_page.pkg2zip_path() self.settings.setValue("ps1_dir", self._ps1_dir) self.settings.setValue("ps1_output_dir", self._ps1_output_dir) self.settings.setValue("ps1_base_pbp", self._ps1_base_pbp) + self.settings.setValue("nps_dir", self._nps_dir) + self.settings.setValue("pkg2zip_path", self._pkg2zip) self.ps1_page.set_dirs(self._ps1_dir, self._ps1_output_dir) self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir) self.settings.setValue("cloud_saves_dir", self._cloud_saves_dir) @@ -3937,7 +4033,7 @@ class MainWindow(QMainWindow): self.statusBar().showMessage("Loading library…") log.info("loading library…") thread = QThread(self) - worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir) + worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir, self._nps_dir) worker.moveToThread(thread) thread.started.connect(worker.run) worker.result.connect(self._on_library_loaded) @@ -4153,13 +4249,23 @@ class MainWindow(QMainWindow): def _on_store_download(self, items): if not items: return - if not self._games_dir: - QMessageBox.warning(self, "No games directory", "Set the games directory in Settings, then scan.") - return src = self.store_page.download_source() log.info("downloading %d item(s) from store", len(items)) for it in items: - self.queue.enqueue(src, self._games_dir, it, store.display_name(it["name"]), kind="download") + if isinstance(src, transfers.NoPayStationSource): + 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" + dest_dir = Path(self._nps_dir) / sub + kind = "nps-download" + else: + if not self._games_dir: + QMessageBox.warning(self, "No games directory", "Set the games directory in Settings, then scan.") + return + dest_dir = self._games_dir + kind = "download" + self.queue.enqueue(src, dest_dir, it, store.display_name(it["name"]), kind=kind) self.tabs.setCurrentWidget(self.queue_page) # -- PSP sync ---------------------------------------------------------- # @@ -4205,10 +4311,10 @@ class MainWindow(QMainWindow): norm = matcher.normalize_name(stem) if norm and norm in self._psp_index.get("names", set()): return True - if item.get("system") == "ps1": - # PS1 EBOOTs install as PSP/GAME/{category}/{GAME_ID}/EBOOT.PBP, so - # match the source game-folder id (or the embedded title) against - # the homebrew index (which includes Category Lite subfolders). + if item.get("system") == "ps1" or item.get("psp_kind") == "eboot": + # PS1 EBOOTs and NoPayStation PSP games live as PSP/GAME/{category}/ + # {GAME_ID}/EBOOT.PBP; match the source game-folder id (or the + # embedded title) against the homebrew index. game_id = Path(item.get("path") or "").parent.name.strip().lower() if game_id and game_id in self._psp_index.get("homebrew_ids", set()): return True @@ -4248,6 +4354,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 path = item.get("path") if not path or not Path(path).exists(): QMessageBox.warning(self, "Missing file", f"File not found:\n{path}") @@ -4320,12 +4428,16 @@ class MainWindow(QMainWindow): psp_root = Path(self._psp_root) if system == "videos": dest_dir = psp_root / "ISO" / "VIDEO" - elif system == "ps1": - # PS1 EBOOTs install as a whole game folder under /PSP/GAME - # (optionally nested in a Category Lite folder). + 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 folder + # for PS1). src_folder = Path(item["path"]).parent - ps1_cat = "".join(c for c in (self._ps1_category or "").strip() if c not in "/\\") - dest_dir = (psp_root / "PSP" / "GAME" / ps1_cat) if ps1_cat else (psp_root / "PSP" / "GAME") + if system == "ps1": + ps1_cat = "".join(c for c in (self._ps1_category or "").strip() if c not in "/\\") + dest_dir = (psp_root / "PSP" / "GAME" / ps1_cat) if ps1_cat else (psp_root / "PSP" / "GAME") + else: + dest_dir = psp_root / "PSP" / "GAME" log.info("copying ps1 game to psp: %s -> %s", src_folder, dest_dir / src_folder.name) self.queue.enqueue(transfers.TreeCopySource(str(src_folder)), dest_dir, {"dest_name": src_folder.name}, item.get("name") or src_folder.name, @@ -4407,9 +4519,9 @@ 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 - if item.get("system") == "ps1": - # PS1 EBOOTs live as PSP/GAME/{category}/{GAME_ID}/EBOOT.PBP; delete - # the whole game folder, matched by the source folder id (or title). + 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). game_id = Path(item.get("path") or "").parent.name.strip().lower() hb = self._psp_homebrew or [] if game_id: @@ -4495,6 +4607,10 @@ class MainWindow(QMainWindow): remaining = any(j.status in ("queued", "running", "cancelling") for j in self.queue.jobs) if not remaining: QTimer.singleShot(0, self._start_scan) + if kind == "nps-download": + remaining = any(j.status in ("queued", "running", "cancelling") for j in self.queue.jobs) + if not remaining: + QTimer.singleShot(0, self.load_library) if kind == "psp-install": QTimer.singleShot(0, self._refresh_psp) if kind == "save-sync": diff --git a/app/nopaystation.py b/app/nopaystation.py new file mode 100644 index 0000000..eca545a --- /dev/null +++ b/app/nopaystation.py @@ -0,0 +1,201 @@ +"""NoPayStation (.pkg) browsing and extraction. + +NoPayStation publishes per-platform TSV lists (``https://nopaystation.com/tsv/``) +with one row per downloadable PSN item. Each row links to an encrypted PSN +``.pkg`` (a ``\\x7FPKG`` container, not a ZIP) that must be decrypted with the +``pkg2zip`` tool before it can be installed as a ``PSP/GAME//`` +folder. +""" +from __future__ import annotations + +import logging +import shutil +import subprocess +import urllib.parse +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from . import config + +log = logging.getLogger("nopaystation") + +_TSV_NAMES = {"psp": "PSP_GAMES.tsv", "psx": "PSX_GAMES.tsv"} + +# TSV columns (tab-separated), in order. +_COLUMNS = ( + "title_id", "region", "name", "pkg_url", "zrif", "content_id", + "date", "size", "sha256", +) + + +def tsv_url(content_type: str, base: str = "") -> str: + """TSV URL for a content type (``psp`` / ``psx``).""" + base = (base or config.NPS_TSV_BASE).rstrip("/") + return f"{base}/{_TSV_NAMES.get(content_type, _TSV_NAMES['psp'])}" + + +def _cache_dir() -> Path: + d = config.STATE_DIR / "nps" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _parse_date(value: str) -> str: + value = (value or "").strip() + if not value: + return "" + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): + try: + return datetime.strptime(value, fmt).strftime("%Y-%m-%d") + except ValueError: + continue + return value[:10] + + +def parse_tsv(text: str, content_type: str) -> List[dict]: + """Parse a NoPayStation TSV into store item dicts. + + Each item carries the fields the Store page expects (``name``, ``size``, + ``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" + items: List[dict] = [] + lines = text.splitlines() + for line in lines: + line = line.rstrip("\r\n") + if not line.strip(): + continue + cols = line.split("\t") + # Skip the header row. + if cols and cols[0].strip().lower() == "title id": + continue + if len(cols) < 5: + continue + row = dict(zip(_COLUMNS, cols)) + name = (row.get("name") or "").strip() + pkg_url = (row.get("pkg_url") or "").strip() + title_id = (row.get("title_id") or "").strip() + if not name or not pkg_url: + continue + try: + size = int(float(row.get("size") or 0)) + except (TypeError, ValueError): + size = 0 + date = _parse_date(row.get("date") or "") + try: + mtime_ns = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1_000_000_000) if date else 0 + except ValueError: + mtime_ns = 0 + items.append({ + "name": name, + "title_id": title_id, + "region": (row.get("region") or "").strip(), + "pkg_url": pkg_url, + "zrif": (row.get("zrif") or "").strip(), + "content_id": (row.get("content_id") or "").strip(), + "size": size, + "sha256": (row.get("sha256") or "").strip().lower(), + "release_date": date, + "mtime_ns": mtime_ns, + "content_type": content_type, + "system": system, + }) + return items + + +def fetch_items(content_type: str, url: str = "", timeout: int = 60) -> List[dict]: + """Download (and cache) a TSV, returning parsed item dicts.""" + url = url or tsv_url(content_type) + name = _TSV_NAMES.get(content_type, "PSP_GAMES.tsv") + cache = _cache_dir() / name + if cache.is_file(): + try: + return parse_tsv(cache.read_text(encoding="utf-8", errors="replace"), content_type) + except OSError: + pass + req = urllib.request.Request(url, headers={"User-Agent": "psp-preserve/0.1"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + text = data.decode("utf-8", errors="replace") + cache.write_text(text, encoding="utf-8") + return parse_tsv(text, content_type) + + +def find_pkg2zip(configured_path: str = "") -> Optional[Path]: + """Locate the pkg2zip binary (configured path, else PATH).""" + if configured_path: + p = Path(configured_path) + return p if p.is_file() else None + found = shutil.which("pkg2zip") + return Path(found) if found else None + + +def _safe_folder(value: str) -> str: + import re + cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper() + return cleaned or "PKG" + + +def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path, + progress_cb=None, cancel_event=None) -> Path: + """Decrypt/extract a ``.pkg`` with pkg2zip into ``dest_dir//``. + + pkg2zip extracts PSP/PSX content into a folder layout we then normalize: + the game's ``EBOOT.PBP`` (plus siblings) are copied into the destination + game folder. Returns the destination folder path. + """ + from .store import TransferCancelled + + pkg_path = Path(pkg_path) + folder_name = _safe_folder(title_id) + dest = Path(dest_dir) / folder_name + if (dest / "EBOOT.PBP").exists(): + return dest + + tmp = Path(dest_dir).with_name(".nps-tmp-" + folder_name) + if tmp.exists(): + shutil.rmtree(tmp, ignore_errors=True) + tmp.mkdir(parents=True, exist_ok=True) + + if progress_cb is not None: + progress_cb("Extracting", 0.0, "") + try: + proc = subprocess.Popen( + [str(pkg2zip), "-x", str(pkg_path)], + cwd=str(tmp), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + ) + assert proc.stdout is not None + for _line in proc.stdout: + if cancel_event is not None and cancel_event.is_set(): + proc.kill() + raise TransferCancelled() + proc.wait() + if proc.returncode != 0: + raise RuntimeError(f"pkg2zip failed (exit {proc.returncode}) for {pkg_path.name}") + finally: + if cancel_event is not None and cancel_event.is_set(): + shutil.rmtree(tmp, ignore_errors=True) + raise TransferCancelled() + + eboots = list(tmp.rglob("EBOOT.PBP")) + if not eboots: + shutil.rmtree(tmp, ignore_errors=True) + raise RuntimeError(f"pkg2zip produced no EBOOT.PBP from {pkg_path.name}") + src_folder = eboots[0].parent + + dest.mkdir(parents=True, exist_ok=True) + for p in sorted(src_folder.rglob("*")): + if not p.is_file(): + continue + rel = p.relative_to(src_folder) + target = dest / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(p, target) + if progress_cb is not None: + progress_cb("Extracting", 1.0, "") + shutil.rmtree(tmp, ignore_errors=True) + return dest diff --git a/app/transfers.py b/app/transfers.py index 018ef28..ac49b3f 100644 --- a/app/transfers.py +++ b/app/transfers.py @@ -463,3 +463,49 @@ class LocalShareSource(Source): return dest finally: shutil.rmtree(tmp, ignore_errors=True) + + +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 ``pkg2zip`` binary and writes a + ``/EBOOT.PBP`` game folder into ``dest_dir``. + """ + + label = "NoPayStation" + + def __init__(self, content_type, tsv_url="", pkg2zip_path=""): + from . import nopaystation + self.content_type = content_type + self.tsv_url = tsv_url or nopaystation.tsv_url(content_type) + self.pkg2zip = pkg2zip_path or "" + + def scan(self, root=None): + from . import nopaystation + return nopaystation.fetch_items(self.content_type, self.tsv_url) + + def download(self, item, dest_dir, progress_cb=None, cancel_event=None): + from . import nopaystation + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + + pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip) + if pkg2zip is None: + raise RuntimeError( + "pkg2zip not found — install it (https://github.com/mmozeiko/pkg2zip) " + "or set its path in Settings → Store." + ) + 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" + tmp = Path(tempfile.mkdtemp(prefix="nps-download-")) + pkg = tmp / f"{pkg_name}.pkg" + try: + if progress_cb is not None: + progress_cb("Downloading", 0.0, "") + download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event) + folder = nopaystation.extract_pkg(pkg, title_id, dest_dir, pkg2zip, + progress_cb, cancel_event) + return folder + finally: + shutil.rmtree(tmp, ignore_errors=True)