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
+154 -38
View File
@@ -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 ``<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):
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":