Compare commits
4
Commits
4b6e407b3f
...
3032f04b72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3032f04b72 | ||
|
|
d02152ca06 | ||
|
|
efc8522117 | ||
|
|
53f671d09a |
@@ -14,3 +14,6 @@ data/*.pbp
|
||||
# User-supplied PS1 Classics art templates (extracted from copyrighted PSN
|
||||
# EBOOTs) — kept local only; the app falls back to drawn art when absent.
|
||||
data/ps1_templates/
|
||||
|
||||
# nps tsv files
|
||||
data/nps/*.tsv
|
||||
@@ -18,9 +18,9 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
||||
- **Library** — scan folders of `.iso` discs, verify against the DAT catalog,
|
||||
browse with cover art, search/filter, see per-game details (developer,
|
||||
publisher, genre, release date, synopsis, trailer), and launch discs in PPSSPP.
|
||||
- **Store** — browse and download discs from three sources: a local 7z folder,
|
||||
your own HTTP store server, or an archive.org item (see
|
||||
[Store](#store--archiveorg-and-local-folder)).
|
||||
- **Store** — browse and download discs from four sources: a local 7z folder,
|
||||
your own HTTP store server, an archive.org item, or NoPayStation (PSP/PS1
|
||||
PSN `.pkg` content) (see [Store](#store--archiveorg-and-local-folder)).
|
||||
- **Queue** — background transfers with progress, cancel, per-row delete, etc.
|
||||
- **PSP Sync** — detect a connected PSP (USB) or a mounted UMD, inventory what's
|
||||
installed (games / UMD videos / homebrew / PKG), copy discs to the PSP, delete
|
||||
@@ -38,6 +38,8 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
||||
- Linux + Python 3
|
||||
- `PySide6` — the only Python dependency (`pip install -r requirements.txt`)
|
||||
- `7z` (p7zip) — used to peek/extract disc images from store archives
|
||||
- `pkg2zip` (optional) — decrypts NoPayStation `.pkg` files (see
|
||||
[NoPayStation](#4-nopaystation))
|
||||
- PPSSPP (optional) — to launch discs from the details panel
|
||||
|
||||
## Install
|
||||
@@ -101,6 +103,8 @@ Discs are matched by hash first (most precise), then serial, then filename.
|
||||
- `data/ps1_templates/` — optional PS1 Classics art templates
|
||||
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
|
||||
to drawn art when they're absent.
|
||||
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan; drop
|
||||
`PSP_GAMES.tsv` / `PSX_GAMES.tsv` manually to work offline).
|
||||
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
|
||||
- `library.sqlite` — the catalog + library database.
|
||||
- `covers/` — downloaded cover cache.
|
||||
@@ -132,6 +136,9 @@ QSettings under `PSPPreserve`) taking precedence where noted.
|
||||
| `PSP_ARCHIVE_ITEM` | `psp-redump-roms321.com` | archive.org item identifier |
|
||||
| `PSP_ARCHIVE_LOGGED_IN_SIG` | *(empty)* | archive.org `logged-in-sig` cookie |
|
||||
| `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie |
|
||||
| `PSP_NPS_DIR` | `<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_CREDENTIALS` | `$PSP_DATA_DIR/credentials.json` | credentials file |
|
||||
| `PSP_LIBRETRO_COVERS_URL` | *(empty)* | direct URL for `libretro-covers.zip` (else derived from store server) |
|
||||
@@ -258,6 +265,33 @@ If the item's files are marked **private** (login required), set the
|
||||
from a logged-in archive.org browser session). Without them, private files are
|
||||
skipped — and you'll get a clear error if *everything* in the item is private.
|
||||
|
||||
### 4. NoPayStation
|
||||
|
||||
The **NoPayStation** source lists PSN content from
|
||||
[NoPayStation](https://nopaystation.com/) — **PSP games** (`PSP_GAMES.tsv`) and
|
||||
**PS1 Classics** (`PSX_GAMES.tsv`), selectable from the content-type dropdown
|
||||
next to the source selector.
|
||||
|
||||
Requirements & behaviour:
|
||||
|
||||
1. Install [`pkg2zip`](https://github.com/mmozeiko/pkg2zip) (the same tool the
|
||||
pkgi family uses) and either put it on `PATH` or set its path in
|
||||
**Settings → Store**. NoPayStation `.pkg` files are encrypted PSN packages,
|
||||
not ZIPs — `pkg2zip` decrypts them.
|
||||
2. Set **NoPayStation output dir** in **Settings → Store** (default
|
||||
`$PSP_STATE_DIR/nps`).
|
||||
3. Downloading a PSP game writes `PSP/<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 `data/nps/` and refreshed on each store scan
|
||||
(drop the `.tsv` files there manually to work offline).
|
||||
|
||||
> **DRM note:** NPS content is PSN-DRM'd. To *run* it on the PSP you need the
|
||||
> [`npdrm_free`](https://github.com/qwikrazor87/npdrm_free) plugin (ARK and most
|
||||
> CFW already ship it). The desktop app only downloads and decrypts; it doesn't
|
||||
> strip DRM.
|
||||
|
||||
## Server setup
|
||||
|
||||
The catalog + store API is a single-file, stdlib-only HTTP server
|
||||
|
||||
@@ -46,6 +46,14 @@ ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com")
|
||||
ARCHIVE_LOGGED_IN_SIG = os.environ.get("PSP_ARCHIVE_LOGGED_IN_SIG", "")
|
||||
ARCHIVE_LOGGED_IN_USER = os.environ.get("PSP_ARCHIVE_LOGGED_IN_USER", "")
|
||||
|
||||
# NoPayStation: where extracted .pkg content lands, and the TSV source base.
|
||||
# PSP games go to <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.
|
||||
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()]
|
||||
|
||||
+155
-39
@@ -682,10 +682,13 @@ 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 "")
|
||||
is_ps1 = item.get("system") == "ps1"
|
||||
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"
|
||||
is_ps1_disc = is_ps1 and item.get("ps1_kind") == "disc"
|
||||
self.play_btn.setEnabled(
|
||||
(item.get("system") == "games" and bool(path) and Path(path).suffix.lower() == ".iso")
|
||||
@@ -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":
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""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"}
|
||||
|
||||
# NoPayStation TSVs are tab-separated with a header row whose exact columns
|
||||
# differ per platform (PSP has "Type"/"RAP" columns, PSX has "Original Name").
|
||||
# Map header names to canonical keys instead of relying on fixed positions.
|
||||
_HEADER_ALIASES = {
|
||||
"title id": "title_id",
|
||||
"region": "region",
|
||||
"name": "name",
|
||||
"pkg direct link": "pkg_url",
|
||||
"content id": "content_id",
|
||||
"last modification date": "date",
|
||||
"file size": "size",
|
||||
"sha256": "sha256",
|
||||
"zrif": "zrif",
|
||||
"rap": "rap",
|
||||
"download .rap file": "rap_url",
|
||||
"type": "content_kind",
|
||||
"original name": "original_name",
|
||||
}
|
||||
|
||||
|
||||
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 _tsv_dir() -> Path:
|
||||
d = config.DATA_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.
|
||||
|
||||
The header row drives column mapping (PSP and PSX TSVs differ). 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] = []
|
||||
header = None
|
||||
for line in text.splitlines():
|
||||
line = line.rstrip("\r\n")
|
||||
if not line.strip():
|
||||
continue
|
||||
cols = line.split("\t")
|
||||
if header is None:
|
||||
header = [c.strip().lower() for c in cols]
|
||||
continue
|
||||
row = {}
|
||||
for i, key in enumerate(header):
|
||||
canon = _HEADER_ALIASES.get(key)
|
||||
if canon and i < len(cols):
|
||||
row[canon] = cols[i].strip()
|
||||
name = row.get("name") or ""
|
||||
pkg_url = row.get("pkg_url") or ""
|
||||
if not name or not pkg_url:
|
||||
continue
|
||||
title_id = row.get("title_id") or ""
|
||||
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 "",
|
||||
"pkg_url": pkg_url,
|
||||
"zrif": row.get("zrif") or "",
|
||||
"content_id": row.get("content_id") or "",
|
||||
"size": size,
|
||||
"sha256": (row.get("sha256") or "").strip().lower(),
|
||||
"release_date": date,
|
||||
"mtime_ns": mtime_ns,
|
||||
"content_type": content_type,
|
||||
"system": system,
|
||||
"content_kind": row.get("content_kind") or "",
|
||||
})
|
||||
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 = _tsv_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
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
class NoPayStationSource(Source):
|
||||
"""Browse NoPayStation .pkg lists and install PSP/PSX PSN content.
|
||||
|
||||
``content_type`` is ``"psp"`` (PSP_GAMES.tsv) or ``"psx"`` (PSX_GAMES.tsv).
|
||||
Downloading decrypts the ``.pkg`` with the ``pkg2zip`` binary and writes a
|
||||
``<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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# NoPayStation TSV lists are cached here (downloaded on first store scan).
|
||||
# You can also drop PSP_GAMES.tsv / PSX_GAMES.tsv here manually to work offline.
|
||||
Reference in New Issue
Block a user