Compare commits
7
Commits
3032f04b72
...
398c9f5dad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398c9f5dad | ||
|
|
2ba24028a0 | ||
|
|
d458a9859c | ||
|
|
6837481f2b | ||
|
|
0ef7d05462 | ||
|
|
de931f422a | ||
|
|
4e0218a562 |
@@ -36,12 +36,14 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
||||
## Requirements
|
||||
|
||||
- Linux + Python 3
|
||||
- `PySide6` — the only Python dependency (`pip install -r requirements.txt`)
|
||||
- `PySide6` + `pycryptodome` (`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
|
||||
|
||||
NoPayStation `.pkg` files are decrypted by the bundled **pure-Python
|
||||
decryptor** (`app/pkg.py`) — no Wine or `pkg2zip` required
|
||||
(see [NoPayStation](#4-nopaystation)).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
@@ -105,6 +107,10 @@ Discs are matched by hash first (most precise), then serial, then filename.
|
||||
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).
|
||||
- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept
|
||||
only as an optional fallback decryptor; the app normally uses its own
|
||||
pure-Python decryptor (`app/pkg.py`) and never needs Wine.
|
||||
See `data/bin/ATTRIBUTION.md`.
|
||||
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
|
||||
- `library.sqlite` — the catalog + library database.
|
||||
- `covers/` — downloaded cover cache.
|
||||
@@ -274,16 +280,17 @@ 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.
|
||||
1. `.pkg` files are decrypted by the built-in **pure-Python decryptor**
|
||||
(`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no
|
||||
`pkg2zip` install needed. A bundled `pkg2zip.exe` is kept as a fallback
|
||||
only, and only runs if Python decryption fails and Wine is present.
|
||||
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.
|
||||
3. Downloading a PSP game writes `PSP/<TITLE_ID>/EBOOT.PBP` (plus
|
||||
`PSP-KEY.EDAT` / `CONTENT.DAT` when the package ships them); a PS1 Classic
|
||||
writes `PSX/<TITLE_ID>/EBOOT.PBP` (+ `DOCUMENT.DAT` and `KEYS.BIN`). 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).
|
||||
|
||||
@@ -364,6 +371,8 @@ Connect a PSP over USB (or mount a UMD) and the **PSP Sync** tab detects it:
|
||||
can dump the disc image into your library.
|
||||
- **Sync from Library** — copy discs from your library onto the PSP, optionally
|
||||
into a Category Lite category (set in **Settings → PSP → ISO category**).
|
||||
NoPayStation PSP games install under `/PSP/GAME`, optionally nested in the
|
||||
**PSP game category**; PS1 Classics use the **PS1 category**.
|
||||
|
||||
## Saves sync
|
||||
|
||||
|
||||
+173
-15
@@ -1075,7 +1075,7 @@ 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,
|
||||
iso_category, ps1_category, psp_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
|
||||
nps_dir="", pkg2zip_path="", parent=None):
|
||||
super().__init__(parent)
|
||||
outer = QVBoxLayout(self)
|
||||
@@ -1112,7 +1112,7 @@ class SettingsPage(QWidget):
|
||||
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.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional fallback):", pkg2zip_path)
|
||||
self.tabs.addTab(page, "Store")
|
||||
|
||||
# -- Saves ------------------------------------------------------- #
|
||||
@@ -1133,6 +1133,9 @@ class SettingsPage(QWidget):
|
||||
self.ps1_category_edit = QLineEdit(ps1_category)
|
||||
self.ps1_category_edit.setPlaceholderText("e.g. PS1 (blank = PSP/GAME root)")
|
||||
form.addRow("PS1 category (Category Lite):", self.ps1_category_edit)
|
||||
self.psp_category_edit = QLineEdit(psp_category)
|
||||
self.psp_category_edit.setPlaceholderText("e.g. psp (blank = PSP/GAME root)")
|
||||
form.addRow("PSP game category (Category Lite):", self.psp_category_edit)
|
||||
self.tabs.addTab(page, "PSP")
|
||||
|
||||
# -- Cover sources ------------------------------------------------ #
|
||||
@@ -1294,6 +1297,9 @@ class SettingsPage(QWidget):
|
||||
def ps1_category(self):
|
||||
return self.ps1_category_edit.text().strip()
|
||||
|
||||
def psp_category(self):
|
||||
return self.psp_category_edit.text().strip()
|
||||
|
||||
def ps1_dir(self):
|
||||
return self.ps1_edit.text().strip()
|
||||
|
||||
@@ -2320,7 +2326,7 @@ class StorePage(QWidget):
|
||||
thread.started.connect(worker.run)
|
||||
worker.progress.connect(self._on_scrape_progress)
|
||||
worker.finished.connect(thread.quit)
|
||||
dlg.canceled.connect(worker.cancel)
|
||||
dlg.canceled.connect(self._on_scrape_cancelled)
|
||||
worker.finished.connect(self._on_scrape_done)
|
||||
self._scrape_thread = thread
|
||||
self._scrape_worker = worker
|
||||
@@ -2328,6 +2334,19 @@ class StorePage(QWidget):
|
||||
self._workers.append(worker)
|
||||
thread.start()
|
||||
|
||||
def _on_scrape_cancelled(self):
|
||||
worker = self._scrape_worker
|
||||
if worker is not None:
|
||||
worker.cancel()
|
||||
dlg = self._scrape_dialog
|
||||
if dlg is not None:
|
||||
dlg.close()
|
||||
# Drop the dialog reference so queued progress signals can't re-show it
|
||||
# (QProgressDialog re-appears on setValue while a value is pending).
|
||||
# Keep _scraping True until the worker actually finishes (_on_scrape_done)
|
||||
# so a re-click doesn't spawn a second scrape while this one winds down.
|
||||
self._scrape_dialog = None
|
||||
|
||||
def _on_scrape_progress(self, done, total):
|
||||
dlg = self._scrape_dialog
|
||||
if dlg is not None:
|
||||
@@ -3353,21 +3372,46 @@ class SavesPage(QWidget):
|
||||
self.sync_btn = QPushButton("Sync now")
|
||||
self.sync_btn.clicked.connect(lambda: self.sync_now(True))
|
||||
bar.addWidget(self.sync_btn)
|
||||
self.delete_btn = QPushButton("Delete selected")
|
||||
self.delete_btn.setEnabled(False)
|
||||
self.delete_btn.setStyleSheet("color: #e06c6c;")
|
||||
self.delete_btn.clicked.connect(self._delete_checked)
|
||||
bar.addWidget(self.delete_btn)
|
||||
bar.addStretch(1)
|
||||
layout.addLayout(bar)
|
||||
|
||||
self.table = QTableWidget(0, 5)
|
||||
self.table.setHorizontalHeaderLabels(["Save", "ID", "PSP", "PPSSPP", "Cloud"])
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.table = QTableWidget(0, 6)
|
||||
self.table.setHorizontalHeaderLabels(["", "Save", "ID", "PSP", "PPSSPP", "Cloud"])
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
|
||||
self.table.setColumnWidth(0, 28)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSortingEnabled(True)
|
||||
self.table.horizontalHeader().setSortIndicator(0, Qt.AscendingOrder)
|
||||
self.table.setIconSize(QSize(56, 40))
|
||||
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.table.customContextMenuRequested.connect(self._on_context_menu)
|
||||
self.table.itemChanged.connect(self._on_item_changed)
|
||||
self.table.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
self.status = QLabel("")
|
||||
layout.addWidget(self.status)
|
||||
self._data = {}
|
||||
self._populating = False
|
||||
|
||||
def _save_icon(self, folder):
|
||||
"""Return a small QIcon from a save folder's ICON0.PNG, or None."""
|
||||
if not folder:
|
||||
return None
|
||||
p = Path(folder) / "ICON0.PNG"
|
||||
if not p.is_file():
|
||||
return None
|
||||
pm = QPixmap(str(p))
|
||||
if pm.isNull():
|
||||
return None
|
||||
return QIcon(_fit_pixmap(pm, 56, 40))
|
||||
|
||||
def set_locations(self, psp_dir, ppsspp_dir, cloud_dir):
|
||||
self._psp_dir = psp_dir or ""
|
||||
@@ -3378,21 +3422,122 @@ class SavesPage(QWidget):
|
||||
def _locations(self):
|
||||
return {"psp": self._psp_dir, "ppsspp": self._ppsspp_dir, "cloud": self._cloud_dir}
|
||||
|
||||
def _checked_ids(self):
|
||||
ids = []
|
||||
for row in range(self.table.rowCount()):
|
||||
cb = self.table.item(row, 0)
|
||||
if cb is not None and cb.checkState() == Qt.Checked:
|
||||
sid = cb.data(Qt.UserRole)
|
||||
if sid:
|
||||
ids.append(sid)
|
||||
return ids
|
||||
|
||||
def _on_item_changed(self, item):
|
||||
if getattr(self, "_populating", False) or item.column() != 0:
|
||||
return
|
||||
self._update_delete_enabled()
|
||||
|
||||
def _update_delete_enabled(self):
|
||||
self.delete_btn.setEnabled(bool(self._checked_ids()))
|
||||
|
||||
def _on_context_menu(self, pos):
|
||||
item = self.table.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
row = item.row()
|
||||
cb = self.table.item(row, 0)
|
||||
sid = cb.data(Qt.UserRole) if cb is not None else None
|
||||
if not sid:
|
||||
id_item = self.table.item(row, 2)
|
||||
sid = id_item.text() if id_item is not None else None
|
||||
if not sid:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
menu.addAction("Export…", lambda: self._export_save(sid))
|
||||
menu.addAction("Delete…", lambda: self._delete_saves([sid]))
|
||||
menu.exec(self.table.viewport().mapToGlobal(pos))
|
||||
|
||||
def _delete_checked(self):
|
||||
ids = self._checked_ids()
|
||||
if ids:
|
||||
self._delete_saves(ids)
|
||||
|
||||
def _delete_saves(self, sids):
|
||||
targets = []
|
||||
seen = set()
|
||||
for sid in sids:
|
||||
for loc in ("psp", "ppsspp", "cloud"):
|
||||
s = self._data.get(loc, {}).get(sid)
|
||||
if not s:
|
||||
continue
|
||||
p = s.get("path")
|
||||
if p and p not in seen:
|
||||
seen.add(p)
|
||||
targets.append(p)
|
||||
if not targets:
|
||||
QMessageBox.information(self, "Delete saves", "No local copies found.")
|
||||
return
|
||||
preview = "\n".join(targets[:8])
|
||||
if len(targets) > 8:
|
||||
preview += f"\n… (+{len(targets) - 8} more)"
|
||||
res = QMessageBox.question(
|
||||
self, "Delete saves",
|
||||
f"Delete these save folders from disk?\n\n{preview}",
|
||||
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
|
||||
)
|
||||
if res != QMessageBox.Yes:
|
||||
return
|
||||
failed = 0
|
||||
for p in targets:
|
||||
if not transfers.remove_path(Path(p)):
|
||||
failed += 1
|
||||
if failed:
|
||||
QMessageBox.warning(self, "Delete incomplete", f"{failed} folder(s) could not be deleted.")
|
||||
self.refresh()
|
||||
|
||||
def _export_save(self, sid):
|
||||
src = None
|
||||
for loc in ("psp", "ppsspp", "cloud"):
|
||||
s = self._data.get(loc, {}).get(sid)
|
||||
if s and Path(s.get("path", "")).is_dir():
|
||||
src = s["path"]
|
||||
break
|
||||
if not src:
|
||||
QMessageBox.information(self, "Export save", "No local copy found.")
|
||||
return
|
||||
dest = QFileDialog.getExistingDirectory(self, "Export save folder")
|
||||
if not dest:
|
||||
return
|
||||
try:
|
||||
transfers.copy_tree(Path(src), Path(dest) / sid)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(self, "Export failed", str(exc))
|
||||
return
|
||||
QMessageBox.information(self, "Export save", f"Exported to:\n{Path(dest) / sid}")
|
||||
|
||||
def refresh(self):
|
||||
locs = self._locations()
|
||||
data = {name: {s["id"]: s for s in saves.list_saves(root)} for name, root in locs.items()}
|
||||
self._data = data
|
||||
all_ids = set()
|
||||
for d in data.values():
|
||||
all_ids.update(d)
|
||||
sort_col = self.table.horizontalHeader().sortIndicatorSection()
|
||||
sort_order = self.table.horizontalHeader().sortIndicatorOrder()
|
||||
self.table.setSortingEnabled(False)
|
||||
self._populating = True
|
||||
self.table.setRowCount(0)
|
||||
for sid in sorted(all_ids):
|
||||
row = self.table.rowCount()
|
||||
self.table.insertRow(row)
|
||||
cb = QTableWidgetItem()
|
||||
cb.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
||||
cb.setCheckState(Qt.Unchecked)
|
||||
cb.setData(Qt.UserRole, sid)
|
||||
self.table.setItem(row, 0, cb)
|
||||
title = sid
|
||||
enc = False
|
||||
icon = None
|
||||
for loc in ("psp", "ppsspp", "cloud"):
|
||||
s = data[loc].get(sid)
|
||||
if s is None:
|
||||
@@ -3401,13 +3546,17 @@ class SavesPage(QWidget):
|
||||
title = s["title"]
|
||||
if s.get("encrypted"):
|
||||
enc = True
|
||||
if icon is None:
|
||||
icon = self._save_icon(s.get("path"))
|
||||
if enc:
|
||||
title += " \u26a0"
|
||||
t = QTableWidgetItem(title)
|
||||
t.setToolTip(sid)
|
||||
self.table.setItem(row, 0, t)
|
||||
self.table.setItem(row, 1, QTableWidgetItem(sid))
|
||||
for col, loc in enumerate(("psp", "ppsspp", "cloud"), start=2):
|
||||
if icon is not None:
|
||||
t.setIcon(icon)
|
||||
self.table.setItem(row, 1, t)
|
||||
self.table.setItem(row, 2, QTableWidgetItem(sid))
|
||||
for col, loc in enumerate(("psp", "ppsspp", "cloud"), start=3):
|
||||
s = data[loc].get(sid)
|
||||
if s is None:
|
||||
self.table.setItem(row, col, QTableWidgetItem(""))
|
||||
@@ -3416,9 +3565,11 @@ class SavesPage(QWidget):
|
||||
item = QTableWidgetItem(stamp)
|
||||
item.setToolTip(f"{s['size'] / 1024:.1f} KB")
|
||||
self.table.setItem(row, col, item)
|
||||
self._populating = False
|
||||
self.table.setSortingEnabled(True)
|
||||
self.table.sortByColumn(sort_col, sort_order)
|
||||
self.status.setText(f"{len(all_ids)} save(s) across {sum(1 for r in locs.values() if r)} location(s)")
|
||||
self._update_delete_enabled()
|
||||
|
||||
def sync_now(self, switch_tab=False):
|
||||
if self._syncing:
|
||||
@@ -3733,6 +3884,7 @@ class MainWindow(QMainWindow):
|
||||
self._ppsspp_exe = str(self.settings.value("ppsspp_exe", "") or "")
|
||||
self._iso_category = str(self.settings.value("iso_category", "") or "")
|
||||
self._ps1_category = str(self.settings.value("ps1_category", "") or "")
|
||||
self._psp_category = str(self.settings.value("psp_category", "") 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_base_pbp = str(self.settings.value("ps1_base_pbp", "") or "")
|
||||
@@ -3947,7 +4099,7 @@ class MainWindow(QMainWindow):
|
||||
self._ppsspp_saves_dir, self._cloud_saves_dir, self._ppsspp_exe,
|
||||
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_category, self._psp_category,
|
||||
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)
|
||||
@@ -3980,6 +4132,7 @@ class MainWindow(QMainWindow):
|
||||
self._ppsspp_exe = self.settings_page.ppsspp_exe()
|
||||
self._iso_category = self.settings_page.iso_category()
|
||||
self._ps1_category = self.settings_page.ps1_category()
|
||||
self._psp_category = self.settings_page.psp_category()
|
||||
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()
|
||||
@@ -3997,6 +4150,7 @@ class MainWindow(QMainWindow):
|
||||
self.settings.setValue("ppsspp_exe", self._ppsspp_exe)
|
||||
self.settings.setValue("iso_category", self._iso_category)
|
||||
self.settings.setValue("ps1_category", self._ps1_category)
|
||||
self.settings.setValue("psp_category", self._psp_category)
|
||||
self._update_saves_locations()
|
||||
self._update_dir_status()
|
||||
|
||||
@@ -4430,15 +4584,16 @@ class MainWindow(QMainWindow):
|
||||
dest_dir = psp_root / "ISO" / "VIDEO"
|
||||
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).
|
||||
# folder under /PSP/GAME (optionally nested in a Category Lite
|
||||
# folder).
|
||||
src_folder = Path(item["path"]).parent
|
||||
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)
|
||||
psp_cat = "".join(c for c in (self._psp_category or "").strip() if c not in "/\\")
|
||||
dest_dir = (psp_root / "PSP" / "GAME" / psp_cat) if psp_cat else (psp_root / "PSP" / "GAME")
|
||||
log.info("copying game folder 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,
|
||||
kind="psp-install")
|
||||
@@ -4918,6 +5073,9 @@ def main():
|
||||
init_db() # migrate legacy on-share DB/covers to local storage, then schema
|
||||
log.info("starting psp-preserve (db: %s)", config.DB_PATH)
|
||||
app = QApplication(sys.argv)
|
||||
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
|
||||
if icon_path.is_file():
|
||||
app.setWindowIcon(QIcon(str(icon_path)))
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
log.info("main window ready")
|
||||
|
||||
+67
-14
@@ -2,13 +2,14 @@
|
||||
|
||||
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.
|
||||
``.pkg`` (a ``\\x7FPKG`` container, not a ZIP) that is decrypted by the bundled
|
||||
pure-Python decryptor (``app/pkg.py``) into a ``PSP/GAME/<TITLE_ID>/``-style
|
||||
folder — no Wine or ``pkg2zip`` required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
@@ -142,36 +143,85 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60) -> List[dic
|
||||
|
||||
|
||||
def find_pkg2zip(configured_path: str = "") -> Optional[Path]:
|
||||
"""Locate the pkg2zip binary (configured path, else PATH)."""
|
||||
"""Locate pkg2zip: configured path, then the bundled binary, then PATH."""
|
||||
if configured_path:
|
||||
p = Path(configured_path)
|
||||
return p if p.is_file() else None
|
||||
bundled = config.DATA_DIR / "bin" / "pkg2zip.exe"
|
||||
if bundled.is_file():
|
||||
return bundled
|
||||
found = shutil.which("pkg2zip")
|
||||
return Path(found) if found else None
|
||||
|
||||
|
||||
def _pkg2zip_cmd(pkg2zip: Path) -> List[str]:
|
||||
"""Command prefix for pkg2zip, wrapping the bundled .exe in Wine on Linux."""
|
||||
if pkg2zip.suffix.lower() == ".exe" and os.name != "nt":
|
||||
wine = shutil.which("wine") or shutil.which("wine64")
|
||||
if not wine:
|
||||
raise RuntimeError(
|
||||
"The bundled pkg2zip is a Windows binary and Wine is not installed — "
|
||||
"install Wine (or point Settings → Store at a native pkg2zip)."
|
||||
)
|
||||
return [wine, str(pkg2zip)]
|
||||
return [str(pkg2zip)]
|
||||
|
||||
|
||||
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>/``.
|
||||
def _ensure_keys_bin(folder, kind):
|
||||
"""PS1 Classics need KEYS.BIN next to EBOOT.PBP to boot on CFW (npdrm)."""
|
||||
if kind != "psx":
|
||||
return
|
||||
src = config.DATA_DIR / "loader" / "KEYS.BIN"
|
||||
dest = Path(folder) / "KEYS.BIN"
|
||||
if src.is_file() and not dest.exists():
|
||||
try:
|
||||
shutil.copy2(src, dest)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
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.
|
||||
|
||||
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
||||
progress_cb=None, cancel_event=None) -> Path:
|
||||
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
|
||||
|
||||
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
|
||||
back to pkg2zip only if that fails and a binary is available. Returns the
|
||||
destination game folder path.
|
||||
"""
|
||||
from . import pkg as pkgmod
|
||||
from .store import TransferCancelled
|
||||
|
||||
pkg_path = Path(pkg_path)
|
||||
folder_name = _safe_folder(title_id)
|
||||
dest = Path(dest_dir) / folder_name
|
||||
dest = Path(dest_dir) / _safe_folder(title_id)
|
||||
if (dest / "EBOOT.PBP").exists():
|
||||
return dest
|
||||
|
||||
# 1. Pure-Python decryptor (primary).
|
||||
try:
|
||||
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, progress_cb, cancel_event)
|
||||
_ensure_keys_bin(folder, _kind)
|
||||
return folder
|
||||
except TransferCancelled:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
py_exc = exc
|
||||
log.warning("python pkg decrypt failed (%s); trying pkg2zip fallback", exc)
|
||||
|
||||
# 2. pkg2zip fallback (needs a binary; usually Wine + the bundled .exe).
|
||||
if pkg2zip is None:
|
||||
pkg2zip = find_pkg2zip()
|
||||
if pkg2zip is None:
|
||||
raise RuntimeError(
|
||||
f"could not decrypt {pkg_path.name} (no pkg2zip available): {py_exc}"
|
||||
)
|
||||
|
||||
folder_name = _safe_folder(title_id)
|
||||
tmp = Path(dest_dir).with_name(".nps-tmp-" + folder_name)
|
||||
if tmp.exists():
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
@@ -180,10 +230,13 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path,
|
||||
if progress_cb is not None:
|
||||
progress_cb("Extracting", 0.0, "")
|
||||
try:
|
||||
cmd = _pkg2zip_cmd(Path(pkg2zip)) + ["-x", str(pkg_path)]
|
||||
env = dict(os.environ)
|
||||
env["WINEDEBUG"] = "-all" # silence Wine's console chatter
|
||||
proc = subprocess.Popen(
|
||||
[str(pkg2zip), "-x", str(pkg_path)],
|
||||
cmd,
|
||||
cwd=str(tmp), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1,
|
||||
text=True, bufsize=1, env=env,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for _line in proc.stdout:
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
"""Pure-Python PSP/PSX PSN ``.pkg`` decryption (no Wine / pkg2zip needed).
|
||||
|
||||
Ports the folder-mode extraction used by pkgi-psp's depackager and pkg2zip:
|
||||
parse the ``\\x7FPKG`` header, derive the PSP AES key, AES-CTR-decrypt the file
|
||||
entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder. The
|
||||
resulting ``EBOOT.PBP`` keeps its DATA.PSP EDAT-encrypted, so it runs on CFW
|
||||
with the ``npdrm_free`` plugin (ARK ships it) — same as pkgi-psp's install.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PKG_MAGIC = 0x7F504B47
|
||||
|
||||
# Static PSN keys (key type 1 = PSP).
|
||||
PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
|
||||
PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8")
|
||||
|
||||
try:
|
||||
from Crypto.Cipher import AES as _AES # pycryptodome (fast)
|
||||
_HAVE_CRYPTO = True
|
||||
except ImportError: # noqa: BLE001
|
||||
_HAVE_CRYPTO = False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pure-Python AES-128 (fallback when pycryptodome is unavailable)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_SBOX = bytes.fromhex(
|
||||
"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
|
||||
"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
|
||||
"09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
|
||||
"d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
|
||||
"cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
|
||||
"e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
|
||||
"ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
|
||||
"e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16"
|
||||
)
|
||||
_RCON = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36)
|
||||
|
||||
|
||||
def _key_schedule(key):
|
||||
w = list(key)
|
||||
for i in range(4, 44):
|
||||
t = list(w[(i - 1) * 4:(i - 1) * 4 + 4])
|
||||
if i % 4 == 0:
|
||||
t = t[1:] + t[:1]
|
||||
t = [_SBOX[b] for b in t]
|
||||
t[0] ^= _RCON[i // 4 - 1]
|
||||
w += [w[(i - 4) * 4 + j] ^ t[j] for j in range(4)]
|
||||
return [bytes(w[i * 16:(i + 1) * 16]) for i in range(11)]
|
||||
|
||||
|
||||
def _shift_rows(s):
|
||||
return [s[0], s[5], s[10], s[15], s[4], s[9], s[14], s[3],
|
||||
s[8], s[13], s[2], s[7], s[12], s[1], s[6], s[11]]
|
||||
|
||||
|
||||
def _xtime(a):
|
||||
a <<= 1
|
||||
return (a ^ 0x1B) & 0xFF if a & 0x100 else a & 0xFF
|
||||
|
||||
|
||||
def _mix_columns(s):
|
||||
out = [0] * 16
|
||||
for c in range(4):
|
||||
a0, a1, a2, a3 = s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3]
|
||||
out[4 * c] = _xtime(a0) ^ (_xtime(a1) ^ a1) ^ a2 ^ a3
|
||||
out[4 * c + 1] = a0 ^ _xtime(a1) ^ (_xtime(a2) ^ a2) ^ a3
|
||||
out[4 * c + 2] = a0 ^ a1 ^ _xtime(a2) ^ (_xtime(a3) ^ a3)
|
||||
out[4 * c + 3] = (_xtime(a0) ^ a0) ^ a1 ^ a2 ^ _xtime(a3)
|
||||
return out
|
||||
|
||||
|
||||
def _aes_encrypt_block(rk, block):
|
||||
s = [block[i] ^ rk[0][i] for i in range(16)]
|
||||
for rnd in range(1, 10):
|
||||
s = [_SBOX[b] for b in s]
|
||||
s = _shift_rows(s)
|
||||
s = _mix_columns(s)
|
||||
s = [s[i] ^ rk[rnd][i] for i in range(16)]
|
||||
s = [_SBOX[b] for b in s]
|
||||
s = _shift_rows(s)
|
||||
s = [s[i] ^ rk[10][i] for i in range(16)]
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _add128(counter, n):
|
||||
c = bytearray(counter)
|
||||
for i in range(15, -1, -1):
|
||||
n += c[i]
|
||||
c[i] = n & 0xFF
|
||||
n >>= 8
|
||||
return bytes(c)
|
||||
|
||||
|
||||
def _ctr_decrypt_py(key, iv, block_index, data):
|
||||
"""AES-CTR decrypt (pure-Python): counter = iv + block_index, +1 per block."""
|
||||
rk = _key_schedule(key)
|
||||
counter = _add128(iv, block_index)
|
||||
out = bytearray()
|
||||
for i in range(0, len(data), 16):
|
||||
ks = _aes_encrypt_block(rk, counter)
|
||||
out += bytes(b ^ k for b, k in zip(data[i:i + 16], ks))
|
||||
counter = _add128(counter, 1)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _ctr_decrypt(key, iv, block_index, data):
|
||||
"""AES-CTR decrypt: 128-bit big-endian counter at ``iv + block_index``."""
|
||||
if _HAVE_CRYPTO:
|
||||
nonce = iv[:8]
|
||||
init = int.from_bytes(iv[8:16], "big") + block_index
|
||||
return _AES.new(key, _AES.MODE_CTR, nonce=nonce, initial_value=init).decrypt(data)
|
||||
return _ctr_decrypt_py(key, iv, block_index, data)
|
||||
|
||||
|
||||
def _make_ctr_decryptor(key, iv, block_index):
|
||||
"""Return a streaming decryptor (callable: bytes -> bytes) for a data run."""
|
||||
if _HAVE_CRYPTO:
|
||||
nonce = iv[:8]
|
||||
init = int.from_bytes(iv[8:16], "big") + block_index
|
||||
cipher = _AES.new(key, _AES.MODE_CTR, nonce=nonce, initial_value=init)
|
||||
return cipher.decrypt
|
||||
rk = _key_schedule(key)
|
||||
counter = _add128(iv, block_index)
|
||||
state = {"rk": rk, "counter": counter}
|
||||
|
||||
def decrypt(chunk):
|
||||
out = bytearray()
|
||||
for i in range(0, len(chunk), 16):
|
||||
ks = _aes_encrypt_block(state["rk"], state["counter"])
|
||||
out += bytes(b ^ k for b, k in zip(chunk[i:i + 16], ks))
|
||||
state["counter"] = _add128(state["counter"], 1)
|
||||
return bytes(out)
|
||||
return decrypt
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PKG extraction
|
||||
# --------------------------------------------------------------------------- #
|
||||
def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
||||
"""Decrypt a PSP/PSX PSN pkg into ``dest_dir/<TITLE_ID>/``.
|
||||
|
||||
Returns ``(kind, title_id, folder)`` where kind is ``"psp"`` or ``"psx"``.
|
||||
"""
|
||||
from .store import TransferCancelled
|
||||
|
||||
pkg_path = Path(pkg_path)
|
||||
dest_dir = Path(dest_dir)
|
||||
|
||||
with open(pkg_path, "rb") as fh:
|
||||
header = fh.read(0x100)
|
||||
if len(header) < 0xE8 or int.from_bytes(header[0:4], "big") != PKG_MAGIC:
|
||||
raise RuntimeError(f"not a PKG file: {pkg_path.name}")
|
||||
|
||||
meta_offset = int.from_bytes(header[8:12], "big")
|
||||
meta_count = int.from_bytes(header[12:16], "big")
|
||||
item_count = int.from_bytes(header[20:24], "big")
|
||||
enc_offset = int.from_bytes(header[32:40], "big")
|
||||
iv = header[0x70:0x80]
|
||||
key_type = header[0xE7] & 7
|
||||
title_id = header[0x37:0x40].decode("ascii", "replace").rstrip("\x00").strip() or "PKG"
|
||||
|
||||
content_type = 0
|
||||
items_offset = 0
|
||||
with open(pkg_path, "rb") as fh:
|
||||
off = meta_offset
|
||||
for _ in range(meta_count):
|
||||
fh.seek(off)
|
||||
m = fh.read(16)
|
||||
if len(m) < 16:
|
||||
break
|
||||
mtype = int.from_bytes(m[0:4], "big")
|
||||
msize = int.from_bytes(m[4:8], "big")
|
||||
if mtype == 2:
|
||||
content_type = int.from_bytes(m[8:12], "big")
|
||||
elif mtype == 13:
|
||||
items_offset = int.from_bytes(m[8:12], "big")
|
||||
off += 8 + msize
|
||||
|
||||
if content_type == 6:
|
||||
kind = "psx"
|
||||
elif content_type in (7, 0xE, 0xF, 0x10):
|
||||
kind = "psp"
|
||||
else:
|
||||
raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}")
|
||||
|
||||
if key_type != 1:
|
||||
raise RuntimeError(f"unsupported PKG key type {key_type}")
|
||||
|
||||
main_key = PSP_KEY
|
||||
ps3_key = PS3_KEY
|
||||
|
||||
# Decrypt the whole item table in one shot.
|
||||
with open(pkg_path, "rb") as fh:
|
||||
fh.seek(enc_offset + items_offset)
|
||||
table = fh.read(item_count * 32)
|
||||
table = _ctr_decrypt(main_key, iv, items_offset // 16, table)
|
||||
|
||||
entries = []
|
||||
for i in range(item_count):
|
||||
e = table[i * 32:(i + 1) * 32]
|
||||
name_offset = int.from_bytes(e[0:4], "big")
|
||||
name_size = int.from_bytes(e[4:8], "big")
|
||||
data_offset = int.from_bytes(e[8:16], "big")
|
||||
data_size = int.from_bytes(e[16:24], "big")
|
||||
psp_type = e[24]
|
||||
flags = e[27]
|
||||
if flags in (4, 18) or data_size == 0:
|
||||
continue
|
||||
item_key = main_key if psp_type == 0x90 else ps3_key
|
||||
entries.append((name_offset, name_size, data_offset, data_size, item_key))
|
||||
|
||||
folder = dest_dir / title_id
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
|
||||
total = sum(ds for *_, ds, _ in entries) or 1
|
||||
done = 0
|
||||
|
||||
with open(pkg_path, "rb") as fh:
|
||||
for name_offset, name_size, data_offset, data_size, item_key in entries:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise TransferCancelled()
|
||||
fh.seek(enc_offset + name_offset)
|
||||
name = _ctr_decrypt(item_key, iv, name_offset // 16, fh.read(name_size))
|
||||
name = name.split(b"\x00", 1)[0].decode("utf-8", "replace")
|
||||
base = name.rsplit("/", 1)[-1]
|
||||
|
||||
if kind == "psx" and base not in ("EBOOT.PBP", "DOCUMENT.DAT"):
|
||||
continue
|
||||
if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"):
|
||||
continue
|
||||
|
||||
dest = folder / base
|
||||
decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16)
|
||||
fh.seek(enc_offset + data_offset)
|
||||
with open(dest, "wb") as out:
|
||||
remaining = data_size
|
||||
while remaining > 0:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise TransferCancelled()
|
||||
chunk = fh.read(min(1 << 20, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
out.write(decrypt(chunk))
|
||||
remaining -= len(chunk)
|
||||
done += len(chunk)
|
||||
if progress_cb is not None:
|
||||
progress_cb("Extracting", min(done / total, 1.0), "")
|
||||
written.append(dest)
|
||||
|
||||
if not (folder / "EBOOT.PBP").exists():
|
||||
raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}")
|
||||
|
||||
return kind, title_id, folder
|
||||
+4
-7
@@ -469,8 +469,8 @@ 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``.
|
||||
Downloading decrypts the ``.pkg`` with the bundled pure-Python decryptor
|
||||
and writes a ``<TITLE_ID>/EBOOT.PBP`` game folder into ``dest_dir``.
|
||||
"""
|
||||
|
||||
label = "NoPayStation"
|
||||
@@ -490,12 +490,9 @@ class NoPayStationSource(Source):
|
||||
dest_dir = Path(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# The bundled Python decryptor handles PSP/PSX directly; pkg2zip is
|
||||
# only an optional fallback if a binary happens to be configured.
|
||||
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-"))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# pkg2zip
|
||||
|
||||
The `pkg2zip.exe` binary in this folder is kept as an optional fallback for
|
||||
decrypting NoPayStation `.pkg` files (PSP / PSX / PS3 / PSVita content). The
|
||||
app normally uses its own pure-Python decryptor (`app/pkg.py`) and only runs
|
||||
this binary if that fails.
|
||||
|
||||
- Project: <https://github.com/mmozeiko/pkg2zip>
|
||||
- Author: Marcin Mikołajczak (mmozeiko)
|
||||
- Version: v1.8 (bundled)
|
||||
- License: MIT
|
||||
|
||||
The Windows binary is run through Wine on Linux/macOS (see
|
||||
`app/nopaystation.py`).
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 Marcin Mikołajczak (mmozeiko)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -1,2 +1,3 @@
|
||||
PySide6>=6.6
|
||||
curl_cffi>=0.7
|
||||
pycryptodome>=3.20
|
||||
|
||||
Reference in New Issue
Block a user