store: add NoPayStation source (PSP + PS1 Classics .pkg)

New 'NoPayStation' option in the Store tab lists PSP_GAMES.tsv and
PSX_GAMES.tsv, downloads each item's encrypted .pkg and decrypts/extracts
it with pkg2zip into a configurable output dir (<dir>/PSP/<TITLE_ID>/ and
<dir>/PSX/<TITLE_ID>/). Downloaded PSP games and PS1 Classics appear in the
Library (games/eboot and ps1/eboot respectively) and copy to the PSP as
PSP/GAME folders.

- app/nopaystation.py: TSV fetch/parse (cached) + pkg2zip discovery/extract.
- app/transfers.py: NoPayStationSource (scan + download).
- Settings: NPS output dir + pkg2zip path (QSettings nps_dir/pkg2zip_path).
- StorePage: NPS source + content-type combo (PSP Games / PS1 Classics),
  per-item cover system, TSV release dates, nps-download job kind.
- Library: _nps_library_items ingest, on-PSP detection, folder copy/delete,
  PPSSPP play disabled for encrypted PSP EBOOTs.
- README: document the source, pkg2zip + npdrm_free requirements.
This commit is contained in:
2026-09-07 21:18:55 -04:00
parent 4b6e407b3f
commit 53f671d09a
5 changed files with 444 additions and 42 deletions
+35 -4
View File
@@ -1,6 +1,6 @@
# PSP Preservation Tool # PSP Preservation Tool
the name is terrible, i know. 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) ![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 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, - **Library** — scan folders of `.iso` discs, verify against the DAT catalog,
browse with cover art, search/filter, see per-game details (developer, browse with cover art, search/filter, see per-game details (developer,
publisher, genre, release date, synopsis, trailer), and launch discs in PPSSPP. publisher, genre, release date, synopsis, trailer), and launch discs in PPSSPP.
- **Store** — browse and download discs from three sources: a local 7z folder, - **Store** — browse and download discs from four sources: a local 7z folder,
your own HTTP store server, or an archive.org item (see your own HTTP store server, an archive.org item, or NoPayStation (PSP/PS1
[Store](#store--archiveorg-and-local-folder)). PSN `.pkg` content) (see [Store](#store--archiveorg-and-local-folder)).
- **Queue** — background transfers with progress, cancel, per-row delete, etc. - **Queue** — background transfers with progress, cancel, per-row delete, etc.
- **PSP Sync** — detect a connected PSP (USB) or a mounted UMD, inventory what's - **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 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 - Linux + Python 3
- `PySide6` — the only Python dependency (`pip install -r requirements.txt`) - `PySide6` — the only Python dependency (`pip install -r requirements.txt`)
- `7z` (p7zip) — used to peek/extract disc images from store archives - `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 - PPSSPP (optional) — to launch discs from the details panel
## Install ## 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_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_SIG` | *(empty)* | archive.org `logged-in-sig` cookie |
| `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie | | `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie |
| `PSP_NPS_DIR` | `<state>/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_API_TOKEN` | *(empty)* | server API bearer token (unset = open) |
| `PSP_CREDENTIALS` | `$PSP_DATA_DIR/credentials.json` | credentials file | | `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) | | `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 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. 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/<TITLE_ID>/EBOOT.PBP`; a PS1 Classic
writes `PSX/<TITLE_ID>/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 ## Server setup
The catalog + store API is a single-file, stdlib-only HTTP server The catalog + store API is a single-file, stdlib-only HTTP server
+8
View File
@@ -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_SIG = os.environ.get("PSP_ARCHIVE_LOGGED_IN_SIG", "")
ARCHIVE_LOGGED_IN_USER = os.environ.get("PSP_ARCHIVE_LOGGED_IN_USER", "") 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 <NPS_OUTPUT_DIR>/PSP/<TITLE_ID>/; 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. # 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()] 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()] VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()]
+154 -38
View File
@@ -682,7 +682,10 @@ class GameDetailsPanel(QWidget):
self.system = item.get("system") self.system = item.get("system")
self._ps1_kind = item.get("ps1_kind") self._ps1_kind = item.get("ps1_kind")
path = item.get("path") or "" 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.setToolTip(path)
self.filename.setVisible(bool(path)) self.filename.setVisible(bool(path))
is_ps1 = item.get("system") == "ps1" 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, 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, 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) super().__init__(parent)
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0) 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_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.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.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") self.tabs.addTab(page, "Store")
# -- Saves ------------------------------------------------------- # # -- Saves ------------------------------------------------------- #
@@ -1264,6 +1270,12 @@ class SettingsPage(QWidget):
def archive_user(self): def archive_user(self):
return self.archive_user_edit.text().strip() 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): def ppsspp_saves_dir(self):
return self.ppsspp_edit.text().strip() return self.ppsspp_edit.text().strip()
@@ -1628,6 +1640,8 @@ class StorePage(QWidget):
self._archive_id = "" self._archive_id = ""
self._archive_sig = "" self._archive_sig = ""
self._archive_user = "" self._archive_user = ""
self._nps_dir = ""
self._pkg2zip = ""
self._items = [] self._items = []
self._filtered = [] self._filtered = []
self._lib_norm = [] self._lib_norm = []
@@ -1657,9 +1671,14 @@ class StorePage(QWidget):
self.search.textChanged.connect(self._schedule_filter) self.search.textChanged.connect(self._schedule_filter)
bar.addWidget(self.search, 1) bar.addWidget(self.search, 1)
self.source_combo = QComboBox() 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) self.source_combo.currentIndexChanged.connect(self._on_source_changed)
bar.addWidget(self.source_combo) 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 = QComboBox()
self.view_combo.addItems(["Cover view", "List view"]) self.view_combo.addItems(["Cover view", "List view"])
bar.addWidget(self.view_combo) bar.addWidget(self.view_combo)
@@ -1738,13 +1757,16 @@ class StorePage(QWidget):
self.status = QLabel("") self.status = QLabel("")
layout.addWidget(self.status) 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._store_dir = local_dir or ""
self._server_url = server_url or "" self._server_url = server_url or ""
self._token = token or "" self._token = token or ""
self._archive_id = archive_id or "" self._archive_id = archive_id or ""
self._archive_sig = archive_sig or "" self._archive_sig = archive_sig or ""
self._archive_user = archive_user or "" self._archive_user = archive_user or ""
self._nps_dir = nps_dir or ""
self._pkg2zip = pkg2zip_path or ""
if self._server_url: if self._server_url:
self.status.setText(f"Store: {self._server_url}") self.status.setText(f"Store: {self._server_url}")
elif self._archive_id: elif self._archive_id:
@@ -1759,9 +1781,20 @@ class StorePage(QWidget):
return transfers.RemoteStoreSource(self._server_url, self._token) return transfers.RemoteStoreSource(self._server_url, self._token)
if self.source_combo.currentIndex() == 1: if self.source_combo.currentIndex() == 1:
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user) 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) 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): def _on_source_changed(self, _idx):
self.nps_type.setVisible(self.source_combo.currentIndex() == 3)
self._items = [] self._items = []
self._filtered = [] self._filtered = []
self._dirty = False self._dirty = False
@@ -1813,6 +1846,11 @@ class StorePage(QWidget):
log.info("store: fetching from archive.org %s", self._archive_id) log.info("store: fetching from archive.org %s", self._archive_id)
self.status.setText("Fetching store from archive.org…") self.status.setText("Fetching store from archive.org…")
worker = RemoteStoreScanWorker(transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)) 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: else:
if not self._store_dir: if not self._store_dir:
self.status.setText("Set the local store folder in Settings (Advanced)") self.status.setText("Set the local store folder in Settings (Advanced)")
@@ -1887,8 +1925,9 @@ class StorePage(QWidget):
if lc is not None: if lc is not None:
rid, system = lc rid, system = lc
else: else:
rid, system = covers.stable_key(it["name"]), "games" rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
it["release_date"] = dates.get((str(rid), system)) or "" if not it.get("release_date"):
it["release_date"] = dates.get((str(rid), system)) or ""
filtered.append(it) filtered.append(it)
filtered.sort(key=self._store_sort_key) filtered.sort(key=self._store_sort_key)
self._filtered = filtered self._filtered = filtered
@@ -1975,7 +2014,7 @@ class StorePage(QWidget):
if lc is not None: if lc is not None:
rid, system = lc rid, system = lc
else: 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) p = covers.cover_path(rid, system)
if p.exists() and covers.file_is_image(p): if p.exists() and covers.file_is_image(p):
item.setIcon(QIcon(_fit_pixmap(QPixmap(str(p)), self.ICON_W, self.ICON_H))) 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: if lc is not None:
rid, system = lc rid, system = lc
else: 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 "" rel = it.get("release_date") or ""
self.table.setItem(row, 4, QTableWidgetItem(rel)) self.table.setItem(row, 4, QTableWidgetItem(rel))
if not rel and system == "games": if not rel and system == "games":
@@ -2111,7 +2150,7 @@ class StorePage(QWidget):
shown = getattr(self.details, "_item", None) shown = getattr(self.details, "_item", None)
if it is not None and shown is not None and it.get("path") == shown.get("path"): if it is not None and shown is not None and it.get("path") == shown.get("path"):
lc = it.get("_lib_cover") 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) self.details._set_cover(rid, system)
def _selected_items(self): def _selected_items(self):
@@ -2141,7 +2180,7 @@ class StorePage(QWidget):
if lc is not None: if lc is not None:
rid, system = lc rid, system = lc
else: 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) cached = info.load(str(rid), system)
if cached is not None: if cached is not None:
self.details.set_metadata(cached) self.details.set_metadata(cached)
@@ -2172,7 +2211,7 @@ class StorePage(QWidget):
if item is None: if item is None:
return return
lc = item.get("_lib_cover") 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) dlg = ScrapeDialog(item, self, rid=rid, system=system)
if dlg.exec() != QDialog.Accepted: if dlg.exec() != QDialog.Accepted:
return return
@@ -2215,8 +2254,9 @@ class StorePage(QWidget):
menu.exec(self.table.viewport().mapToGlobal(pos)) menu.exec(self.table.viewport().mapToGlobal(pos))
def _search_cover(self, it): def _search_cover(self, it):
system = it.get("system") or "games"
rid = covers.stable_key(it["name"]) 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: if dlg.exec() == QDialog.Accepted:
meta = dlg.selected_meta meta = dlg.selected_meta
if meta: if meta:
@@ -2250,9 +2290,7 @@ class StorePage(QWidget):
if lc is not None: if lc is not None:
rid, system = lc rid, system = lc
else: else:
rid, system = covers.stable_key(it["name"]), "games" rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
if system != "games":
continue
key = (str(rid), system) key = (str(rid), system)
if key in seen: if key in seen:
continue continue
@@ -2345,6 +2383,7 @@ class QueuePage(QWidget):
"ps1-convert": "PS1 convert", "ps1-convert": "PS1 convert",
"umd-dump": "UMD dump", "umd-dump": "UMD dump",
"save-sync": "Save sync", "save-sync": "Save sync",
"nps-download": "NoPayStation",
} }
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -3095,7 +3134,7 @@ class PspSyncPage(QWidget):
self._workers = [] 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.""" """Build the library item list from the DB with a single joined query."""
items = [] items = []
seen = set() seen = set()
@@ -3165,6 +3204,7 @@ def build_library_items(ps1_dir="", output_dir=""):
finally: finally:
conn.close() conn.close()
items.extend(_ps1_library_items(ps1_dir, output_dir, overrides, dates)) items.extend(_ps1_library_items(ps1_dir, output_dir, overrides, dates))
items.extend(_nps_library_items(nps_dir, overrides, dates))
return items return items
@@ -3206,18 +3246,65 @@ def _ps1_library_items(ps1_dir, output_dir, overrides, dates):
return out return out
def _nps_library_items(nps_dir, overrides, dates):
"""NoPayStation-downloaded PSP games and PS1 Classics, merged into the library.
Scans ``<nps>/PSP/*/EBOOT.PBP`` (PSP PSN games) and ``<nps>/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): class LibraryWorker(QObject):
result = Signal(list) result = Signal(list)
finished = Signal() finished = Signal()
def __init__(self, ps1_dir="", output_dir=""): def __init__(self, ps1_dir="", output_dir="", nps_dir=""):
super().__init__() super().__init__()
self.ps1_dir = ps1_dir self.ps1_dir = ps1_dir
self.output_dir = output_dir self.output_dir = output_dir
self.nps_dir = nps_dir
def run(self): def run(self):
try: 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 except Exception: # noqa: BLE001
items = [] items = []
self.result.emit(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_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_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._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("games dir: %s", self._games_dir or "(none)")
log.info("videos dir: %s", self._videos_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)") 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.tabs.addTab(self._build_library_tab(), "Library")
self.store_page = StorePage() self.store_page = StorePage()
self.store_page.set_store_config(self._store_dir, self._store_server_url, self._store_token, 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.store_page.download_requested.connect(self._on_store_download)
self.tabs.addTab(self.store_page, "Store") self.tabs.addTab(self.store_page, "Store")
self.queue_page = QueuePage() self.queue_page = QueuePage()
@@ -3858,7 +3948,8 @@ class MainWindow(QMainWindow):
self._store_server_url, self._store_token, self._store_archive_id, self._store_server_url, self._store_token, self._store_archive_id,
self._store_archive_sig, self._store_archive_user, self._iso_category, self._store_archive_sig, self._store_archive_user, self._iso_category,
self._ps1_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) self.settings_page.saved.connect(self._on_settings_saved)
return self.settings_page 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_sig", self._store_archive_sig)
self.settings.setValue("store_archive_user", self._store_archive_user) 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_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.store_page.refresh()
self._ppsspp_saves_dir = self.settings_page.ppsspp_saves_dir() self._ppsspp_saves_dir = self.settings_page.ppsspp_saves_dir()
self._cloud_saves_dir = self.settings_page.cloud_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_dir = self.settings_page.ps1_dir()
self._ps1_output_dir = self.settings_page.ps1_output_dir() self._ps1_output_dir = self.settings_page.ps1_output_dir()
self._ps1_base_pbp = self.settings_page.ps1_base_pbp() 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_dir", self._ps1_dir)
self.settings.setValue("ps1_output_dir", self._ps1_output_dir) self.settings.setValue("ps1_output_dir", self._ps1_output_dir)
self.settings.setValue("ps1_base_pbp", self._ps1_base_pbp) 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.ps1_page.set_dirs(self._ps1_dir, self._ps1_output_dir)
self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir) self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir)
self.settings.setValue("cloud_saves_dir", self._cloud_saves_dir) self.settings.setValue("cloud_saves_dir", self._cloud_saves_dir)
@@ -3937,7 +4033,7 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage("Loading library…") self.statusBar().showMessage("Loading library…")
log.info("loading library…") log.info("loading library…")
thread = QThread(self) 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) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.result.connect(self._on_library_loaded) worker.result.connect(self._on_library_loaded)
@@ -4153,13 +4249,23 @@ class MainWindow(QMainWindow):
def _on_store_download(self, items): def _on_store_download(self, items):
if not items: if not items:
return 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() src = self.store_page.download_source()
log.info("downloading %d item(s) from store", len(items)) log.info("downloading %d item(s) from store", len(items))
for it in 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) self.tabs.setCurrentWidget(self.queue_page)
# -- PSP sync ---------------------------------------------------------- # # -- PSP sync ---------------------------------------------------------- #
@@ -4205,10 +4311,10 @@ class MainWindow(QMainWindow):
norm = matcher.normalize_name(stem) norm = matcher.normalize_name(stem)
if norm and norm in self._psp_index.get("names", set()): if norm and norm in self._psp_index.get("names", set()):
return True return True
if item.get("system") == "ps1": if item.get("system") == "ps1" or item.get("psp_kind") == "eboot":
# PS1 EBOOTs install as PSP/GAME/{category}/{GAME_ID}/EBOOT.PBP, so # PS1 EBOOTs and NoPayStation PSP games live as PSP/GAME/{category}/
# match the source game-folder id (or the embedded title) against # {GAME_ID}/EBOOT.PBP; match the source game-folder id (or the
# the homebrew index (which includes Category Lite subfolders). # embedded title) against the homebrew index.
game_id = Path(item.get("path") or "").parent.name.strip().lower() 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()): if game_id and game_id in self._psp_index.get("homebrew_ids", set()):
return True return True
@@ -4248,6 +4354,8 @@ class MainWindow(QMainWindow):
return # UMD Video discs can't be played in PPSSPP return # UMD Video discs can't be played in PPSSPP
if item.get("system") == "ps1" and item.get("ps1_kind") != "eboot": if item.get("system") == "ps1" and item.get("ps1_kind") != "eboot":
return # raw PS1 discs are converted, not played 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") path = item.get("path")
if not path or not Path(path).exists(): if not path or not Path(path).exists():
QMessageBox.warning(self, "Missing file", f"File not found:\n{path}") QMessageBox.warning(self, "Missing file", f"File not found:\n{path}")
@@ -4320,12 +4428,16 @@ class MainWindow(QMainWindow):
psp_root = Path(self._psp_root) psp_root = Path(self._psp_root)
if system == "videos": if system == "videos":
dest_dir = psp_root / "ISO" / "VIDEO" dest_dir = psp_root / "ISO" / "VIDEO"
elif system == "ps1": elif system == "ps1" or item.get("psp_kind") == "eboot":
# PS1 EBOOTs install as a whole game folder under /PSP/GAME # PS1 EBOOTs and NoPayStation PSP games install as a whole game
# (optionally nested in a Category Lite folder). # folder under /PSP/GAME (optionally nested in a Category Lite folder
# for PS1).
src_folder = Path(item["path"]).parent src_folder = Path(item["path"]).parent
ps1_cat = "".join(c for c in (self._ps1_category or "").strip() if c not in "/\\") if system == "ps1":
dest_dir = (psp_root / "PSP" / "GAME" / ps1_cat) if ps1_cat else (psp_root / "PSP" / "GAME") 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) 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, self.queue.enqueue(transfers.TreeCopySource(str(src_folder)), dest_dir,
{"dest_name": src_folder.name}, item.get("name") or src_folder.name, {"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.") QMessageBox.information(self, "Not on PSP", "That disc is not installed on the PSP.")
return return
stem = Path(item.get("canonical") or item.get("name") or "").stem stem = Path(item.get("canonical") or item.get("name") or "").stem
if item.get("system") == "ps1": if item.get("system") == "ps1" or item.get("psp_kind") == "eboot":
# PS1 EBOOTs live as PSP/GAME/{category}/{GAME_ID}/EBOOT.PBP; delete # PS1 EBOOTs / NoPayStation PSP games: delete the whole game folder,
# the whole game folder, matched by the source folder id (or title). # matched by the source folder id (or title).
game_id = Path(item.get("path") or "").parent.name.strip().lower() game_id = Path(item.get("path") or "").parent.name.strip().lower()
hb = self._psp_homebrew or [] hb = self._psp_homebrew or []
if game_id: if game_id:
@@ -4495,6 +4607,10 @@ class MainWindow(QMainWindow):
remaining = any(j.status in ("queued", "running", "cancelling") for j in self.queue.jobs) remaining = any(j.status in ("queued", "running", "cancelling") for j in self.queue.jobs)
if not remaining: if not remaining:
QTimer.singleShot(0, self._start_scan) 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": if kind == "psp-install":
QTimer.singleShot(0, self._refresh_psp) QTimer.singleShot(0, self._refresh_psp)
if kind == "save-sync": if kind == "save-sync":
+201
View File
@@ -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/<TITLE_ID>/``
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/<TITLE_ID>/``.
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
+46
View File
@@ -463,3 +463,49 @@ class LocalShareSource(Source):
return dest return dest
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) 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
``<TITLE_ID>/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)