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:
@@ -1,6 +1,6 @@
|
|||||||
# PSP Preservation Tool
|
# PSP Preservation Tool
|
||||||
the name is terrible, i know.
|
the name is terrible, i know.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user