Compare commits
12
Commits
398c9f5dad
...
318a920873
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
318a920873 | ||
|
|
ec9f5e6d8e | ||
|
|
be54ff00aa | ||
|
|
115cdd84b0 | ||
|
|
62ce4b1913 | ||
|
|
46c675ce79 | ||
|
|
42fac24cf1 | ||
|
|
7cc5debfa6 | ||
|
|
c03f4619e6 | ||
|
|
1be33be4ab | ||
|
|
2fed95da05 | ||
|
|
c94bcdc993 |
@@ -274,9 +274,14 @@ skipped — and you'll get a clear error if *everything* in the item is private.
|
|||||||
### 4. NoPayStation
|
### 4. NoPayStation
|
||||||
|
|
||||||
The **NoPayStation** source lists PSN content from
|
The **NoPayStation** source lists PSN content from
|
||||||
[NoPayStation](https://nopaystation.com/) — **PSP games** (`PSP_GAMES.tsv`) and
|
[NoPayStation](https://nopaystation.com/), selectable from the content-type
|
||||||
**PS1 Classics** (`PSX_GAMES.tsv`), selectable from the content-type dropdown
|
dropdown next to the source selector:
|
||||||
next to the source selector.
|
|
||||||
|
- **PSP Games** (`PSP_GAMES.tsv`) — includes **minis** (also listed separately
|
||||||
|
in the `Type` column), PC Engine and NeoGeo releases.
|
||||||
|
- **PS1 Classics** (`PSX_GAMES.tsv`)
|
||||||
|
- **PSP Themes** (`PSP_THEMES.tsv`)
|
||||||
|
- **PSP DLC** (`PSP_DLCS.tsv`)
|
||||||
|
|
||||||
Requirements & behaviour:
|
Requirements & behaviour:
|
||||||
|
|
||||||
@@ -286,18 +291,28 @@ Requirements & behaviour:
|
|||||||
only, and only runs if Python decryption fails and Wine is present.
|
only, and only runs if Python decryption fails and Wine is present.
|
||||||
2. Set **NoPayStation output dir** in **Settings → Store** (default
|
2. Set **NoPayStation output dir** in **Settings → Store** (default
|
||||||
`$PSP_STATE_DIR/nps`).
|
`$PSP_STATE_DIR/nps`).
|
||||||
3. Downloading a PSP game writes `PSP/<TITLE_ID>/EBOOT.PBP` (plus
|
3. Downloads land in the NPS folder and appear in the **Library**:
|
||||||
`PSP-KEY.EDAT` / `CONTENT.DAT` when the package ships them); a PS1 Classic
|
- PSP game → `PSP/<TITLE_ID>/EBOOT.PBP` (`psp_kind="eboot"`).
|
||||||
writes `PSX/<TITLE_ID>/EBOOT.PBP` (+ `DOCUMENT.DAT` and `KEYS.BIN`). Both
|
- PS1 Classic → `PSX/<TITLE_ID>/EBOOT.PBP` (+ `DOCUMENT.DAT`, `KEYS.BIN`).
|
||||||
then show up in the **Library** (PS1 Classics under **PS1**, PSP games
|
- Theme → `THEME/<TITLE_ID>/<content_id>.ptf` (the packaged PTF is
|
||||||
under **Games**) and can be copied to the PSP like any other item.
|
EDAT-unwrapped to a raw theme file).
|
||||||
4. The TSV lists are cached in `data/nps/` and refreshed on each store scan
|
- DLC → `DLC/<GAME_ID>/<content_id>.edat` (installed as-is into the owning
|
||||||
|
game's folder).
|
||||||
|
4. Copying to the PSP puts games/PS1 under `/PSP/GAME/` (optionally nested in
|
||||||
|
a Category Lite folder), themes under `/PSP/THEME/`, and DLC under
|
||||||
|
`/PSP/GAME/<GAME_ID>/`.
|
||||||
|
5. The TSV lists are cached in `data/nps/` and refreshed on each store scan
|
||||||
(drop the `.tsv` files there manually to work offline).
|
(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
|
> **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
|
> [`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
|
> CFW already ship it). The desktop app only downloads and decrypts; it doesn't
|
||||||
> strip DRM.
|
> strip DRM.
|
||||||
|
>
|
||||||
|
> **Covers/metadata:** themes and DLC have no cover art or game metadata of
|
||||||
|
> their own — the app scrapes them like games (IGDB/libretro/etc.), which can
|
||||||
|
> return unrelated results. Downloaded themes keep their embedded preview as
|
||||||
|
> the primary cover (used instead of a scraped image when available).
|
||||||
|
|
||||||
## Server setup
|
## Server setup
|
||||||
|
|
||||||
@@ -365,8 +380,8 @@ token**, then pick **Server** in the Store tab's source selector. Use
|
|||||||
|
|
||||||
Connect a PSP over USB (or mount a UMD) and the **PSP Sync** tab detects it:
|
Connect a PSP over USB (or mount a UMD) and the **PSP Sync** tab detects it:
|
||||||
|
|
||||||
- **Games / UMD Videos / Homebrew-PSN / PKG** — tables of what's installed, with
|
- **Games / UMD Videos / Homebrew-PSN / Themes / DLC / PKG** — tables of what's
|
||||||
delete-from-PSP (checkboxes and right-click).
|
installed, with delete-from-PSP (checkboxes and right-click).
|
||||||
- **UMD** — reads `PARAM.SFO` from a mounted UMD, shows the title/serial, and
|
- **UMD** — reads `PARAM.SFO` from a mounted UMD, shows the title/serial, and
|
||||||
can dump the disc image into your library.
|
can dump the disc image into your library.
|
||||||
- **Sync from Library** — copy discs from your library onto the PSP, optionally
|
- **Sync from Library** — copy discs from your library onto the PSP, optionally
|
||||||
|
|||||||
+237
-61
@@ -5,6 +5,7 @@ Run with::
|
|||||||
python3 -m app.main
|
python3 -m app.main
|
||||||
"""
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -20,7 +21,7 @@ from PySide6.QtWidgets import (
|
|||||||
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import config, covers, info, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, transfers
|
from . import config, covers, info, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, theme, transfers
|
||||||
from .db import get_connection, init_db
|
from .db import get_connection, init_db
|
||||||
|
|
||||||
log = logging.getLogger("gui")
|
log = logging.getLogger("gui")
|
||||||
@@ -429,7 +430,7 @@ class CoverShowcase(QWidget):
|
|||||||
if item is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
data = item.data(Qt.UserRole)
|
data = item.data(Qt.UserRole)
|
||||||
if data and data.get("system") != "videos":
|
if data and data.get("system") not in ("videos", "theme", "dlc"):
|
||||||
self.play_requested.emit(data)
|
self.play_requested.emit(data)
|
||||||
|
|
||||||
def _on_failed(self, gen, index, name):
|
def _on_failed(self, gen, index, name):
|
||||||
@@ -467,13 +468,12 @@ class CoverShowcase(QWidget):
|
|||||||
if it is not None and it.get("path"):
|
if it is not None and it.get("path"):
|
||||||
menu.addAction("Open containing folder", lambda: open_containing_folder(it.get("path")))
|
menu.addAction("Open containing folder", lambda: open_containing_folder(it.get("path")))
|
||||||
if it is not None:
|
if it is not None:
|
||||||
if it.get("system") != "videos":
|
if it.get("system") not in ("videos", "theme", "dlc"):
|
||||||
menu.addAction("Play on PPSSPP", lambda: self.play_requested.emit(it))
|
menu.addAction("Play on PPSSPP", lambda: self.play_requested.emit(it))
|
||||||
menu.addAction("Copy to PSP", lambda: self.copy_requested.emit(it))
|
menu.addAction("Copy to PSP", lambda: self.copy_requested.emit(it))
|
||||||
if it.get("on_psp"):
|
if it.get("on_psp"):
|
||||||
menu.addAction("Delete from PSP", lambda: self.delete_psp_requested.emit(it))
|
menu.addAction("Delete from PSP", lambda: self.delete_psp_requested.emit(it))
|
||||||
menu.addAction("Delete…", lambda: self.delete_requested.emit(it))
|
menu.addAction("Delete…", lambda: self.delete_requested.emit(it))
|
||||||
if it is not None:
|
|
||||||
menu.addAction("Scrape…", lambda: self.scrape_requested.emit(it))
|
menu.addAction("Scrape…", lambda: self.scrape_requested.emit(it))
|
||||||
menu.exec(self.list.mapToGlobal(pos))
|
menu.exec(self.list.mapToGlobal(pos))
|
||||||
|
|
||||||
@@ -1499,7 +1499,7 @@ class StoreDetailsPanel(QWidget):
|
|||||||
if lc is not None:
|
if lc is not None:
|
||||||
rid, system = lc
|
rid, system = lc
|
||||||
else:
|
else:
|
||||||
rid, system = covers.stable_key(item["name"]), "games"
|
rid, system = covers.stable_key(item["name"]), item.get("system") or "games"
|
||||||
self._set_cover(rid, system)
|
self._set_cover(rid, system)
|
||||||
|
|
||||||
def _set_cover(self, rid, system):
|
def _set_cover(self, rid, system):
|
||||||
@@ -1650,11 +1650,12 @@ class StorePage(QWidget):
|
|||||||
self._pkg2zip = ""
|
self._pkg2zip = ""
|
||||||
self._items = []
|
self._items = []
|
||||||
self._filtered = []
|
self._filtered = []
|
||||||
self._lib_norm = []
|
self._lib_norm = set()
|
||||||
self._lib_cover = {} # norm -> (release_id, system)
|
self._lib_cover = {} # norm -> (release_id, system)
|
||||||
self._psp_norm = [] # norm of library items installed on the PSP
|
self._psp_norm = set() # norm of library items installed on the PSP
|
||||||
self._cover_attempted = set()
|
self._cover_attempted = set()
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
|
self._scan_gen = 0
|
||||||
self._meta_gen = 0
|
self._meta_gen = 0
|
||||||
self._closing = False
|
self._closing = False
|
||||||
self._threads = []
|
self._threads = []
|
||||||
@@ -1681,7 +1682,7 @@ class StorePage(QWidget):
|
|||||||
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 = QComboBox()
|
||||||
self.nps_type.addItems(["PSP Games", "PS1 Classics"])
|
self.nps_type.addItems(["PSP Games", "PS1 Classics", "PSP Themes", "PSP DLC"])
|
||||||
self.nps_type.setVisible(False)
|
self.nps_type.setVisible(False)
|
||||||
self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed)
|
self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed)
|
||||||
bar.addWidget(self.nps_type)
|
bar.addWidget(self.nps_type)
|
||||||
@@ -1694,7 +1695,7 @@ class StorePage(QWidget):
|
|||||||
self.psp_filter.currentIndexChanged.connect(self._apply_filter)
|
self.psp_filter.currentIndexChanged.connect(self._apply_filter)
|
||||||
bar.addWidget(self.psp_filter)
|
bar.addWidget(self.psp_filter)
|
||||||
refresh_btn = QPushButton("Refresh")
|
refresh_btn = QPushButton("Refresh")
|
||||||
refresh_btn.clicked.connect(self.refresh)
|
refresh_btn.clicked.connect(lambda: self.refresh(force=True))
|
||||||
bar.addWidget(refresh_btn)
|
bar.addWidget(refresh_btn)
|
||||||
self.all_check = QCheckBox("All")
|
self.all_check = QCheckBox("All")
|
||||||
self.all_check.setChecked(False)
|
self.all_check.setChecked(False)
|
||||||
@@ -1788,7 +1789,7 @@ class StorePage(QWidget):
|
|||||||
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:
|
if self.source_combo.currentIndex() == 3:
|
||||||
ct = "psp" if self.nps_type.currentIndex() == 0 else "psx"
|
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
|
||||||
return transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip)
|
return transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip)
|
||||||
return transfers.LocalShareSource(self._store_dir)
|
return transfers.LocalShareSource(self._store_dir)
|
||||||
|
|
||||||
@@ -1807,14 +1808,14 @@ class StorePage(QWidget):
|
|||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def set_library_items(self, items):
|
def set_library_items(self, items):
|
||||||
norm, seen, cover = [], set(), {}
|
norm, seen, cover = set(), set(), {}
|
||||||
psp_norm, psp_seen = [], set()
|
psp_norm, psp_seen = set(), set()
|
||||||
for it in items or []:
|
for it in items or []:
|
||||||
# The store only contains PSP game discs, so ignore UMD videos when
|
# The store lists PSP games/themes/DLC; ignore UMD videos (a game
|
||||||
# matching against the library (a game archive must never match a
|
# archive must never match a same-named movie) and PS1 (its titles
|
||||||
# same-named movie and inherit its cover/metadata).
|
# are matched separately).
|
||||||
system = it.get("system")
|
system = it.get("system")
|
||||||
if system != "games":
|
if system not in ("games", "theme", "dlc"):
|
||||||
continue
|
continue
|
||||||
rid = it.get("release_id")
|
rid = it.get("release_id")
|
||||||
for key in (it.get("canonical"), it.get("name")):
|
for key in (it.get("canonical"), it.get("name")):
|
||||||
@@ -1823,21 +1824,23 @@ class StorePage(QWidget):
|
|||||||
n = matcher.normalize_name(key)
|
n = matcher.normalize_name(key)
|
||||||
if n and n not in seen:
|
if n and n not in seen:
|
||||||
seen.add(n)
|
seen.add(n)
|
||||||
norm.append(n)
|
norm.add(n)
|
||||||
if rid is not None:
|
if rid is not None:
|
||||||
cover[n] = (rid, system)
|
cover[n] = (rid, system)
|
||||||
if it.get("on_psp") and n and n not in psp_seen:
|
if it.get("on_psp") and n and n not in psp_seen:
|
||||||
psp_seen.add(n)
|
psp_seen.add(n)
|
||||||
psp_norm.append(n)
|
psp_norm.add(n)
|
||||||
self._lib_norm = norm
|
self._lib_norm = norm
|
||||||
self._lib_cover = cover
|
self._lib_cover = cover
|
||||||
self._psp_norm = psp_norm
|
self._psp_norm = psp_norm
|
||||||
if self._items:
|
if self._items:
|
||||||
self._apply_filter()
|
self._apply_filter()
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self, force=False):
|
||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
|
self._scan_gen += 1
|
||||||
|
gen = self._scan_gen
|
||||||
if self.source_combo.currentIndex() == 0: # Server
|
if self.source_combo.currentIndex() == 0: # Server
|
||||||
if not self._server_url:
|
if not self._server_url:
|
||||||
self.status.setText("Set the store server URL in Settings")
|
self.status.setText("Set the store server URL in Settings")
|
||||||
@@ -1853,10 +1856,10 @@ class StorePage(QWidget):
|
|||||||
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
|
elif self.source_combo.currentIndex() == 3: # NoPayStation
|
||||||
ct = "psp" if self.nps_type.currentIndex() == 0 else "psx"
|
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
|
||||||
log.info("store: fetching NoPayStation %s list", ct)
|
log.info("store: fetching NoPayStation %s list", ct)
|
||||||
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…")
|
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…")
|
||||||
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip))
|
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip, force=force))
|
||||||
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)")
|
||||||
@@ -1865,17 +1868,33 @@ class StorePage(QWidget):
|
|||||||
self.status.setText("Scanning local store…")
|
self.status.setText("Scanning local store…")
|
||||||
worker = StoreScanWorker(self._store_dir)
|
worker = StoreScanWorker(self._store_dir)
|
||||||
thread = QThread(self)
|
thread = QThread(self)
|
||||||
|
worker.gen = gen # used by _on_scan_results/_on_scan_failed via sender()
|
||||||
worker.moveToThread(thread)
|
worker.moveToThread(thread)
|
||||||
thread.started.connect(worker.run)
|
thread.started.connect(worker.run)
|
||||||
worker.results.connect(self._on_scan_results)
|
worker.results.connect(self._on_scan_results)
|
||||||
worker.failed.connect(self._on_scan_failed)
|
worker.failed.connect(self._on_scan_failed)
|
||||||
worker.finished.connect(thread.quit)
|
worker.finished.connect(thread.quit)
|
||||||
|
worker.finished.connect(self._on_worker_finished)
|
||||||
|
thread.finished.connect(self._on_thread_finished)
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
self._workers.append(worker)
|
self._workers.append(worker)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
def _on_thread_finished(self):
|
||||||
|
thread = self.sender()
|
||||||
|
if thread in self._threads:
|
||||||
|
self._threads.remove(thread)
|
||||||
|
thread.deleteLater()
|
||||||
|
|
||||||
|
def _on_worker_finished(self):
|
||||||
|
worker = self.sender()
|
||||||
|
if worker in self._workers:
|
||||||
|
self._workers.remove(worker)
|
||||||
|
worker.deleteLater()
|
||||||
|
|
||||||
def _on_scan_failed(self, message):
|
def _on_scan_failed(self, message):
|
||||||
if self._closing:
|
gen = getattr(self.sender(), "gen", self._scan_gen)
|
||||||
|
if self._closing or gen != self._scan_gen:
|
||||||
return
|
return
|
||||||
self._items = []
|
self._items = []
|
||||||
self._filtered = []
|
self._filtered = []
|
||||||
@@ -1884,7 +1903,8 @@ class StorePage(QWidget):
|
|||||||
self.status.setText(f"Store unavailable: {message}")
|
self.status.setText(f"Store unavailable: {message}")
|
||||||
|
|
||||||
def _on_scan_results(self, items):
|
def _on_scan_results(self, items):
|
||||||
if self._closing:
|
gen = getattr(self.sender(), "gen", self._scan_gen)
|
||||||
|
if self._closing or gen != self._scan_gen:
|
||||||
return
|
return
|
||||||
self._items = items
|
self._items = items
|
||||||
self._cover_attempted.clear()
|
self._cover_attempted.clear()
|
||||||
@@ -1914,15 +1934,11 @@ class StorePage(QWidget):
|
|||||||
# Exact normalized match only: region/language/version tags are
|
# Exact normalized match only: region/language/version tags are
|
||||||
# already stripped, and substring matching would false-positive
|
# already stripped, and substring matching would false-positive
|
||||||
# sequels (e.g. "Open Tee" matching "Open Tee 2").
|
# sequels (e.g. "Open Tee" matching "Open Tee 2").
|
||||||
for ln in self._lib_norm:
|
if n in self._lib_norm:
|
||||||
if n == ln:
|
it["in_library"] = True
|
||||||
it["in_library"] = True
|
it["_lib_cover"] = self._lib_cover.get(n)
|
||||||
it["_lib_cover"] = self._lib_cover.get(ln)
|
if n in self._psp_norm:
|
||||||
break
|
it["on_psp"] = True
|
||||||
for pn in self._psp_norm:
|
|
||||||
if n == pn:
|
|
||||||
it["on_psp"] = True
|
|
||||||
break
|
|
||||||
if psp_f == "On PSP" and not it.get("on_psp"):
|
if psp_f == "On PSP" and not it.get("on_psp"):
|
||||||
continue
|
continue
|
||||||
if psp_f == "Not on PSP" and it.get("on_psp"):
|
if psp_f == "Not on PSP" and it.get("on_psp"):
|
||||||
@@ -1958,7 +1974,7 @@ class StorePage(QWidget):
|
|||||||
CHUNK = 150 # items filled per timer tick
|
CHUNK = 150 # items filled per timer tick
|
||||||
COVER_BATCH = 200 # covers fetched for the initially-visible area
|
COVER_BATCH = 200 # covers fetched for the initially-visible area
|
||||||
COVER_SCROLL_BATCH = 100
|
COVER_SCROLL_BATCH = 100
|
||||||
RECENT_LIMIT = 150 # default: show only the newest N store items
|
RECENT_LIMIT = 300 # default: show only the newest N store items
|
||||||
DATE_BATCH = 150 # release-date metadata fetched per batch
|
DATE_BATCH = 150 # release-date metadata fetched per batch
|
||||||
|
|
||||||
def _populate(self, items, show_count):
|
def _populate(self, items, show_count):
|
||||||
@@ -2120,6 +2136,7 @@ class StorePage(QWidget):
|
|||||||
thread.started.connect(loader.run)
|
thread.started.connect(loader.run)
|
||||||
loader.loaded.connect(self._on_date_loaded)
|
loader.loaded.connect(self._on_date_loaded)
|
||||||
loader.finished.connect(thread.quit)
|
loader.finished.connect(thread.quit)
|
||||||
|
thread.finished.connect(self._on_thread_finished)
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
self._loaders.append(loader)
|
self._loaders.append(loader)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -2142,6 +2159,7 @@ class StorePage(QWidget):
|
|||||||
thread.started.connect(loader.run)
|
thread.started.connect(loader.run)
|
||||||
loader.loaded.connect(self._on_cover_loaded)
|
loader.loaded.connect(self._on_cover_loaded)
|
||||||
loader.finished.connect(thread.quit)
|
loader.finished.connect(thread.quit)
|
||||||
|
thread.finished.connect(self._on_thread_finished)
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
self._loaders.append(loader)
|
self._loaders.append(loader)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -2198,6 +2216,8 @@ class StorePage(QWidget):
|
|||||||
thread.started.connect(worker.run)
|
thread.started.connect(worker.run)
|
||||||
worker.result.connect(self._on_store_meta)
|
worker.result.connect(self._on_store_meta)
|
||||||
worker.finished.connect(thread.quit)
|
worker.finished.connect(thread.quit)
|
||||||
|
worker.finished.connect(self._on_worker_finished)
|
||||||
|
thread.finished.connect(self._on_thread_finished)
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
self._workers.append(worker)
|
self._workers.append(worker)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -2381,7 +2401,7 @@ class StorePage(QWidget):
|
|||||||
dlg = self._scrape_dialog
|
dlg = self._scrape_dialog
|
||||||
if dlg is not None:
|
if dlg is not None:
|
||||||
dlg.close()
|
dlg.close()
|
||||||
for t in self._threads:
|
for t in list(self._threads):
|
||||||
try:
|
try:
|
||||||
if t.isRunning():
|
if t.isRunning():
|
||||||
t.quit()
|
t.quit()
|
||||||
@@ -2575,10 +2595,11 @@ class PspSyncPage(QWidget):
|
|||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._root = ""
|
self._root = ""
|
||||||
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": []}
|
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": [], "themes": [], "dlc": []}
|
||||||
self._library_items = []
|
self._library_items = []
|
||||||
self._lib_raw = {}
|
self._lib_raw = {}
|
||||||
self._lib_norm = {}
|
self._lib_norm = {}
|
||||||
|
self._lib_path_name = {}
|
||||||
self._gen = 0
|
self._gen = 0
|
||||||
self._umd_gen = 0
|
self._umd_gen = 0
|
||||||
self._umd_iso = None
|
self._umd_iso = None
|
||||||
@@ -2614,16 +2635,21 @@ class PspSyncPage(QWidget):
|
|||||||
self.games_table = self._make_table(["", "Name", "Category", "Added", "Release", "Region", "Size"], stretch_col=1)
|
self.games_table = self._make_table(["", "Name", "Category", "Added", "Release", "Region", "Size"], stretch_col=1)
|
||||||
self.videos_table = self._make_table(["", "Name", "Added", "Size"], stretch_col=1)
|
self.videos_table = self._make_table(["", "Name", "Added", "Size"], stretch_col=1)
|
||||||
self.homebrew_table = self._make_table(["", "ID", "Name", "Added", "Size"], stretch_col=2)
|
self.homebrew_table = self._make_table(["", "ID", "Name", "Added", "Size"], stretch_col=2)
|
||||||
|
self.themes_table = self._make_table(["", "Name", "Added", "Size"], stretch_col=1)
|
||||||
|
self.dlc_table = self._make_table(["", "Game", "Name", "Added", "Size"], stretch_col=2)
|
||||||
self.pkg_table = self._make_table(["", "Name", "Added", "Size"], stretch_col=1)
|
self.pkg_table = self._make_table(["", "Name", "Added", "Size"], stretch_col=1)
|
||||||
self.tabs.addTab(self.games_table, "Games")
|
self.tabs.addTab(self.games_table, "Games")
|
||||||
self.tabs.addTab(self.videos_table, "UMD Videos")
|
self.tabs.addTab(self.videos_table, "UMD Videos")
|
||||||
self.tabs.addTab(self.homebrew_table, "Homebrew / PSN")
|
self.tabs.addTab(self.homebrew_table, "Homebrew / PSN")
|
||||||
|
self.tabs.addTab(self.themes_table, "Themes")
|
||||||
|
self.tabs.addTab(self.dlc_table, "DLC")
|
||||||
self.tabs.addTab(self.pkg_table, "PKG")
|
self.tabs.addTab(self.pkg_table, "PKG")
|
||||||
self.tabs.addTab(self._build_umd_tab(), "UMD")
|
self.tabs.addTab(self._build_umd_tab(), "UMD")
|
||||||
self.tabs.addTab(self._build_sync_tab(), "Sync from Library")
|
self.tabs.addTab(self._build_sync_tab(), "Sync from Library")
|
||||||
layout.addWidget(self.tabs, 1)
|
layout.addWidget(self.tabs, 1)
|
||||||
|
|
||||||
for tbl in (self.games_table, self.videos_table, self.homebrew_table, self.pkg_table):
|
for tbl in (self.games_table, self.videos_table, self.homebrew_table,
|
||||||
|
self.themes_table, self.dlc_table, self.pkg_table):
|
||||||
tbl.itemSelectionChanged.connect(self._update_delete_enabled)
|
tbl.itemSelectionChanged.connect(self._update_delete_enabled)
|
||||||
tbl.itemChanged.connect(self._update_delete_enabled)
|
tbl.itemChanged.connect(self._update_delete_enabled)
|
||||||
tbl.setContextMenuPolicy(Qt.CustomContextMenu)
|
tbl.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
@@ -2665,15 +2691,20 @@ class PspSyncPage(QWidget):
|
|||||||
self._library_items = list(items or [])
|
self._library_items = list(items or [])
|
||||||
self._lib_raw = {}
|
self._lib_raw = {}
|
||||||
self._lib_norm = {}
|
self._lib_norm = {}
|
||||||
|
self._lib_path_name = {}
|
||||||
for it in self._library_items:
|
for it in self._library_items:
|
||||||
label = it.get("canonical") or it.get("name") or ""
|
label = it.get("canonical") or it.get("name") or ""
|
||||||
if not label:
|
if label:
|
||||||
continue
|
stem = Path(label).stem
|
||||||
stem = Path(label).stem
|
self._lib_raw.setdefault(stem.lower(), it)
|
||||||
self._lib_raw.setdefault(stem.lower(), it)
|
n = matcher.normalize_name(stem)
|
||||||
n = matcher.normalize_name(stem)
|
if n:
|
||||||
if n:
|
self._lib_norm.setdefault(n, it)
|
||||||
self._lib_norm.setdefault(n, it)
|
# Map the on-disk filename to the friendly name for themes/DLC
|
||||||
|
# (the PSP only stores the raw file, so resolve names by filename).
|
||||||
|
p = it.get("path") or ""
|
||||||
|
if p:
|
||||||
|
self._lib_path_name.setdefault(Path(p).name.lower(), label)
|
||||||
self._filter_sync()
|
self._filter_sync()
|
||||||
self._populate(self._inventory)
|
self._populate(self._inventory)
|
||||||
|
|
||||||
@@ -2687,7 +2718,7 @@ class PspSyncPage(QWidget):
|
|||||||
self.sync_search.textChanged.connect(self._filter_sync)
|
self.sync_search.textChanged.connect(self._filter_sync)
|
||||||
bar.addWidget(self.sync_search, 1)
|
bar.addWidget(self.sync_search, 1)
|
||||||
self.sync_type = QComboBox()
|
self.sync_type = QComboBox()
|
||||||
self.sync_type.addItems(["All", "Games", "Videos"])
|
self.sync_type.addItems(["All", "Games", "Videos", "PS1", "Themes", "DLC"])
|
||||||
self.sync_type.currentTextChanged.connect(self._filter_sync)
|
self.sync_type.currentTextChanged.connect(self._filter_sync)
|
||||||
bar.addWidget(self.sync_type)
|
bar.addWidget(self.sync_type)
|
||||||
self.sync_installed = QComboBox()
|
self.sync_installed = QComboBox()
|
||||||
@@ -2859,6 +2890,12 @@ class PspSyncPage(QWidget):
|
|||||||
continue
|
continue
|
||||||
if typ == "Videos" and it.get("system") != "videos":
|
if typ == "Videos" and it.get("system") != "videos":
|
||||||
continue
|
continue
|
||||||
|
if typ == "PS1" and it.get("system") != "ps1":
|
||||||
|
continue
|
||||||
|
if typ == "Themes" and it.get("system") != "theme":
|
||||||
|
continue
|
||||||
|
if typ == "DLC" and it.get("system") != "dlc":
|
||||||
|
continue
|
||||||
on_psp = bool(it.get("on_psp"))
|
on_psp = bool(it.get("on_psp"))
|
||||||
if inst == "Not installed" and on_psp:
|
if inst == "Not installed" and on_psp:
|
||||||
continue
|
continue
|
||||||
@@ -2882,7 +2919,8 @@ class PspSyncPage(QWidget):
|
|||||||
title = QTableWidgetItem(label)
|
title = QTableWidgetItem(label)
|
||||||
title.setData(Qt.UserRole, it)
|
title.setData(Qt.UserRole, it)
|
||||||
self.sync_table.setItem(row, 1, title)
|
self.sync_table.setItem(row, 1, title)
|
||||||
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1"}.get(it.get("system"), "Game")
|
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1",
|
||||||
|
"theme": "Theme", "dlc": "DLC"}.get(it.get("system"), "Game")
|
||||||
self.sync_table.setItem(row, 2, QTableWidgetItem(kind_label))
|
self.sync_table.setItem(row, 2, QTableWidgetItem(kind_label))
|
||||||
size = it.get("size")
|
size = it.get("size")
|
||||||
self.sync_table.setItem(row, 3, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size or 0))
|
self.sync_table.setItem(row, 3, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size or 0))
|
||||||
@@ -2935,7 +2973,7 @@ class PspSyncPage(QWidget):
|
|||||||
self._gen += 1
|
self._gen += 1
|
||||||
gen = self._gen
|
gen = self._gen
|
||||||
if not self._root:
|
if not self._root:
|
||||||
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": []}
|
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": [], "themes": [], "dlc": []}
|
||||||
self.status.setText("No PSP connected")
|
self.status.setText("No PSP connected")
|
||||||
self._populate(self._inventory)
|
self._populate(self._inventory)
|
||||||
return
|
return
|
||||||
@@ -2955,21 +2993,26 @@ class PspSyncPage(QWidget):
|
|||||||
if gen != self._gen:
|
if gen != self._gen:
|
||||||
return
|
return
|
||||||
if "error" in res:
|
if "error" in res:
|
||||||
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": []}
|
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": [], "themes": [], "dlc": []}
|
||||||
self.status.setText(f"Error reading PSP: {res['error']}")
|
self.status.setText(f"Error reading PSP: {res['error']}")
|
||||||
log.warning("psp inventory error: %s", res["error"])
|
log.warning("psp inventory error: %s", res["error"])
|
||||||
else:
|
else:
|
||||||
inventory = res["inventory"]
|
inventory = res["inventory"]
|
||||||
self._inventory = inventory
|
self._inventory = inventory
|
||||||
log.info("psp inventory: %d game(s), %d video(s), %d homebrew, %d pkg",
|
log.info("psp inventory: %d game(s), %d video(s), %d homebrew, %d theme(s), %d dlc, %d pkg",
|
||||||
len(inventory["games"]), len(inventory["videos"]),
|
len(inventory["games"]), len(inventory["videos"]),
|
||||||
len(inventory["homebrew"]), len(inventory.get("pkg", [])))
|
len(inventory["homebrew"]), len(inventory.get("themes", [])),
|
||||||
|
len(inventory.get("dlc", [])), len(inventory.get("pkg", [])))
|
||||||
usage = res.get("usage")
|
usage = res.get("usage")
|
||||||
parts = [
|
parts = [
|
||||||
f"{len(inventory['games'])} game(s)",
|
f"{len(inventory['games'])} game(s)",
|
||||||
f"{len(inventory['videos'])} video(s)",
|
f"{len(inventory['videos'])} video(s)",
|
||||||
f"{len(inventory['homebrew'])} homebrew",
|
f"{len(inventory['homebrew'])} homebrew",
|
||||||
]
|
]
|
||||||
|
if inventory.get("themes"):
|
||||||
|
parts.append(f"{len(inventory['themes'])} theme(s)")
|
||||||
|
if inventory.get("dlc"):
|
||||||
|
parts.append(f"{len(inventory['dlc'])} dlc")
|
||||||
if usage is not None:
|
if usage is not None:
|
||||||
free_mb = usage["free"] / (1024 * 1024)
|
free_mb = usage["free"] / (1024 * 1024)
|
||||||
total_mb = usage["total"] / (1024 * 1024)
|
total_mb = usage["total"] / (1024 * 1024)
|
||||||
@@ -2983,6 +3026,8 @@ class PspSyncPage(QWidget):
|
|||||||
self._fill_table(self.games_table, inventory.get("games", []), "games")
|
self._fill_table(self.games_table, inventory.get("games", []), "games")
|
||||||
self._fill_table(self.videos_table, inventory.get("videos", []), "videos")
|
self._fill_table(self.videos_table, inventory.get("videos", []), "videos")
|
||||||
self._fill_table(self.homebrew_table, inventory.get("homebrew", []), "homebrew")
|
self._fill_table(self.homebrew_table, inventory.get("homebrew", []), "homebrew")
|
||||||
|
self._fill_table(self.themes_table, inventory.get("themes", []), "theme")
|
||||||
|
self._fill_table(self.dlc_table, inventory.get("dlc", []), "dlc")
|
||||||
self._fill_table(self.pkg_table, inventory.get("pkg", []), "pkg")
|
self._fill_table(self.pkg_table, inventory.get("pkg", []), "pkg")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -3031,6 +3076,12 @@ class PspSyncPage(QWidget):
|
|||||||
table.setItem(row, 2, name)
|
table.setItem(row, 2, name)
|
||||||
table.setItem(row, 3, QTableWidgetItem(added))
|
table.setItem(row, 3, QTableWidgetItem(added))
|
||||||
table.setItem(row, 4, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size))
|
table.setItem(row, 4, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size))
|
||||||
|
elif kind == "dlc":
|
||||||
|
friendly = self._lib_path_name.get((it.get("name") or "").lower())
|
||||||
|
table.setItem(row, 1, QTableWidgetItem(it.get("id") or ""))
|
||||||
|
table.setItem(row, 2, QTableWidgetItem(friendly or it.get("name") or ""))
|
||||||
|
table.setItem(row, 3, QTableWidgetItem(added))
|
||||||
|
table.setItem(row, 4, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size))
|
||||||
else:
|
else:
|
||||||
table.setItem(row, 1, name)
|
table.setItem(row, 1, name)
|
||||||
table.setItem(row, 2, QTableWidgetItem(added))
|
table.setItem(row, 2, QTableWidgetItem(added))
|
||||||
@@ -3040,7 +3091,8 @@ class PspSyncPage(QWidget):
|
|||||||
|
|
||||||
def _current_inventory_table(self):
|
def _current_inventory_table(self):
|
||||||
w = self.tabs.currentWidget()
|
w = self.tabs.currentWidget()
|
||||||
if w in (self.games_table, self.videos_table, self.homebrew_table, self.pkg_table):
|
if w in (self.games_table, self.videos_table, self.homebrew_table,
|
||||||
|
self.themes_table, self.dlc_table, self.pkg_table):
|
||||||
return w
|
return w
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -3266,11 +3318,13 @@ def _ps1_library_items(ps1_dir, output_dir, overrides, dates):
|
|||||||
|
|
||||||
|
|
||||||
def _nps_library_items(nps_dir, overrides, dates):
|
def _nps_library_items(nps_dir, overrides, dates):
|
||||||
"""NoPayStation-downloaded PSP games and PS1 Classics, merged into the library.
|
"""NoPayStation-downloaded content, merged into the library.
|
||||||
|
|
||||||
Scans ``<nps>/PSP/*/EBOOT.PBP`` (PSP PSN games) and ``<nps>/PSX/*/EBOOT.PBP``
|
Scans ``<nps>/PSP/*/EBOOT.PBP`` (PSP PSN games), ``<nps>/PSX/*/EBOOT.PBP``
|
||||||
(PS1 Classics). PSP games are ``system="games"`` with ``psp_kind="eboot"``;
|
(PS1 Classics), ``<nps>/THEME/*/*.ptf`` (themes) and ``<nps>/DLC/*/*.edat``
|
||||||
PSX games are ``system="ps1"`` with ``ps1_kind="eboot"``.
|
(DLC). PSP games are ``system="games"`` with ``psp_kind="eboot"``; PSX games
|
||||||
|
are ``system="ps1"`` with ``ps1_kind="eboot"``; themes/DLC use
|
||||||
|
``psp_kind="theme"/"dlc"``.
|
||||||
"""
|
"""
|
||||||
if not nps_dir:
|
if not nps_dir:
|
||||||
return []
|
return []
|
||||||
@@ -3308,9 +3362,83 @@ def _nps_library_items(nps_dir, overrides, dates):
|
|||||||
else:
|
else:
|
||||||
item["psp_kind"] = "eboot"
|
item["psp_kind"] = "eboot"
|
||||||
out.append(item)
|
out.append(item)
|
||||||
|
|
||||||
|
# Themes: <nps>/THEME/<TITLE_ID>/<name>.ptf (extension case varies)
|
||||||
|
theme_dir = root / "THEME"
|
||||||
|
if theme_dir.is_dir():
|
||||||
|
for ptf in sorted(theme_dir.rglob("*")):
|
||||||
|
if not ptf.is_file() or ptf.suffix.lower() != ".ptf":
|
||||||
|
continue
|
||||||
|
folder = ptf.parent
|
||||||
|
name = _sidecar_name(folder) or theme.ptf_name(ptf) or ptf.stem
|
||||||
|
item = _nps_extra_item(ptf, "theme", name)
|
||||||
|
item["release_id"] = covers.stable_key(name)
|
||||||
|
_write_theme_cover(ptf, item["release_id"])
|
||||||
|
out.append(item)
|
||||||
|
|
||||||
|
# DLC: <nps>/DLC/<GAME_ID>/<name>.edat
|
||||||
|
dlc_dir = root / "DLC"
|
||||||
|
if dlc_dir.is_dir():
|
||||||
|
for edat in sorted(dlc_dir.rglob("*")):
|
||||||
|
if not edat.is_file() or edat.suffix.lower() != ".edat":
|
||||||
|
continue
|
||||||
|
if edat.name.upper() == "ISO.BIN.EDAT":
|
||||||
|
continue # package wrapper, not DLC content
|
||||||
|
name = _sidecar_name(edat.parent) or edat.stem
|
||||||
|
out.append(_nps_extra_item(edat, "dlc", name))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_name(folder):
|
||||||
|
"""Read a display name saved next to a downloaded NPS item, if present."""
|
||||||
|
try:
|
||||||
|
meta = json.loads((Path(folder) / "meta.json").read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return ""
|
||||||
|
return (meta.get("name") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_theme_cover(ptf_path, rid):
|
||||||
|
"""Extract a theme's embedded preview into the cover cache (idempotent)."""
|
||||||
|
path = covers.cover_path(rid, "theme")
|
||||||
|
if path.exists() and covers.file_is_image(path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = theme.theme_preview_rgba(ptf_path)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
res = None
|
||||||
|
if not res:
|
||||||
|
return
|
||||||
|
w, h, rgba = res
|
||||||
|
img = QImage(rgba, w, h, w * 4, QImage.Format_RGBA8888)
|
||||||
|
if img.isNull():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
img.save(str(path), "PNG")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _nps_extra_item(path, psp_kind, name):
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
rid = covers.stable_key(name)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"release_id": rid,
|
||||||
|
"system": psp_kind,
|
||||||
|
"canonical": name,
|
||||||
|
"serial": "",
|
||||||
|
"region": None,
|
||||||
|
"release_date": "",
|
||||||
|
"path": str(path),
|
||||||
|
"size": size,
|
||||||
|
"psp_kind": psp_kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class LibraryWorker(QObject):
|
class LibraryWorker(QObject):
|
||||||
result = Signal(list)
|
result = Signal(list)
|
||||||
finished = Signal()
|
finished = Signal()
|
||||||
@@ -3957,7 +4085,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.library_search.textChanged.connect(self._apply_library_filter)
|
self.library_search.textChanged.connect(self._apply_library_filter)
|
||||||
bar.addWidget(self.library_search, 1)
|
bar.addWidget(self.library_search, 1)
|
||||||
self.library_type = QComboBox()
|
self.library_type = QComboBox()
|
||||||
self.library_type.addItems(["All types", "Games", "Videos", "PS1"])
|
self.library_type.addItems(["All types", "Games", "Videos", "PS1", "Themes", "DLC"])
|
||||||
self.library_type.currentTextChanged.connect(self._apply_library_filter)
|
self.library_type.currentTextChanged.connect(self._apply_library_filter)
|
||||||
bar.addWidget(self.library_type)
|
bar.addWidget(self.library_type)
|
||||||
self.library_psp = QComboBox()
|
self.library_psp = QComboBox()
|
||||||
@@ -4227,6 +4355,10 @@ class MainWindow(QMainWindow):
|
|||||||
continue
|
continue
|
||||||
if typ == "PS1" and it["system"] != "ps1":
|
if typ == "PS1" and it["system"] != "ps1":
|
||||||
continue
|
continue
|
||||||
|
if typ == "Themes" and it["system"] != "theme":
|
||||||
|
continue
|
||||||
|
if typ == "DLC" and it["system"] != "dlc":
|
||||||
|
continue
|
||||||
if q and q not in (it["canonical"] or it["name"]).lower():
|
if q and q not in (it["canonical"] or it["name"]).lower():
|
||||||
continue
|
continue
|
||||||
if psp_f == "On PSP" and not it.get("on_psp"):
|
if psp_f == "On PSP" and not it.get("on_psp"):
|
||||||
@@ -4252,7 +4384,8 @@ class MainWindow(QMainWindow):
|
|||||||
title.setIcon(self._psp_dot_icon())
|
title.setIcon(self._psp_dot_icon())
|
||||||
title.setToolTip("Installed on PSP")
|
title.setToolTip("Installed on PSP")
|
||||||
self.library_table.setItem(row, 0, title)
|
self.library_table.setItem(row, 0, title)
|
||||||
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1"}.get(it["system"], "Game")
|
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1",
|
||||||
|
"theme": "Theme", "dlc": "DLC"}.get(it["system"], "Game")
|
||||||
self.library_table.setItem(row, 1, QTableWidgetItem(kind_label))
|
self.library_table.setItem(row, 1, QTableWidgetItem(kind_label))
|
||||||
self.library_table.setItem(row, 2, QTableWidgetItem(it.get("region") or ""))
|
self.library_table.setItem(row, 2, QTableWidgetItem(it.get("region") or ""))
|
||||||
self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or ""))
|
self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or ""))
|
||||||
@@ -4276,7 +4409,7 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
cell = self.library_table.item(item.row(), 0)
|
cell = self.library_table.item(item.row(), 0)
|
||||||
it = cell.data(Qt.UserRole) if cell is not None else None
|
it = cell.data(Qt.UserRole) if cell is not None else None
|
||||||
if it and it.get("system") != "videos":
|
if it and it.get("system") not in ("videos", "theme", "dlc"):
|
||||||
self._play_item_on_ppsspp(it)
|
self._play_item_on_ppsspp(it)
|
||||||
|
|
||||||
def _on_library_table_menu(self, pos):
|
def _on_library_table_menu(self, pos):
|
||||||
@@ -4289,7 +4422,7 @@ class MainWindow(QMainWindow):
|
|||||||
if not it:
|
if not it:
|
||||||
return
|
return
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
if it.get("system") != "videos":
|
if it.get("system") not in ("videos", "theme", "dlc"):
|
||||||
menu.addAction("Play on PPSSPP", lambda: self._play_item_on_ppsspp(it))
|
menu.addAction("Play on PPSSPP", lambda: self._play_item_on_ppsspp(it))
|
||||||
menu.addAction("Copy to PSP", lambda: self._copy_to_psp(it))
|
menu.addAction("Copy to PSP", lambda: self._copy_to_psp(it))
|
||||||
if it.get("on_psp"):
|
if it.get("on_psp"):
|
||||||
@@ -4410,7 +4543,7 @@ class MainWindow(QMainWindow):
|
|||||||
if not self._nps_dir:
|
if not self._nps_dir:
|
||||||
QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.")
|
QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.")
|
||||||
return
|
return
|
||||||
sub = "PSP" if src.content_type == "psp" else "PSX"
|
sub = {"psp": "PSP", "psx": "PSX", "theme": "THEME", "dlc": "DLC"}.get(src.content_type, "PSP")
|
||||||
dest_dir = Path(self._nps_dir) / sub
|
dest_dir = Path(self._nps_dir) / sub
|
||||||
kind = "nps-download"
|
kind = "nps-download"
|
||||||
else:
|
else:
|
||||||
@@ -4457,6 +4590,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._maybe_auto_sync()
|
self._maybe_auto_sync()
|
||||||
|
|
||||||
def _on_psp(self, item):
|
def _on_psp(self, item):
|
||||||
|
psp_kind = item.get("psp_kind")
|
||||||
|
if psp_kind in ("theme", "dlc") and self._psp_root:
|
||||||
|
name = Path(item.get("path") or "").name
|
||||||
|
if psp_kind == "theme":
|
||||||
|
return (Path(self._psp_root) / "PSP" / "THEME" / name).exists()
|
||||||
|
game_id = Path(item.get("path") or "").parent.name
|
||||||
|
return (Path(self._psp_root) / "PSP" / "GAME" / game_id / name).exists()
|
||||||
if not self._psp_index:
|
if not self._psp_index:
|
||||||
return False
|
return False
|
||||||
stem = Path(item.get("canonical") or item.get("name") or "").stem
|
stem = Path(item.get("canonical") or item.get("name") or "").stem
|
||||||
@@ -4508,8 +4648,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":
|
if item.get("psp_kind") in ("eboot", "theme", "dlc"):
|
||||||
return # encrypted PSP PSN EBOOTs can't run in PPSSPP
|
return # encrypted PSN EBOOTs/themes/DLC 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}")
|
||||||
@@ -4582,6 +4722,23 @@ 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 item.get("psp_kind") == "theme":
|
||||||
|
# Themes install as a single .ptf under /PSP/THEME.
|
||||||
|
dest_name = Path(item["path"]).name
|
||||||
|
dest_dir = psp_root / "PSP" / "THEME"
|
||||||
|
src = transfers.FileCopySource(item["path"])
|
||||||
|
log.info("copying theme to psp: %s -> %s", item["path"], dest_dir / dest_name)
|
||||||
|
self.queue.enqueue(src, dest_dir, {"dest_name": dest_name}, dest_name, kind="psp-install")
|
||||||
|
return True
|
||||||
|
elif item.get("psp_kind") == "dlc":
|
||||||
|
# DLC installs as a single .edat into /PSP/GAME/<GAME_ID>.
|
||||||
|
game_id = Path(item["path"]).parent.name
|
||||||
|
dest_name = Path(item["path"]).name
|
||||||
|
dest_dir = psp_root / "PSP" / "GAME" / game_id
|
||||||
|
src = transfers.FileCopySource(item["path"])
|
||||||
|
log.info("copying dlc to psp: %s -> %s", item["path"], dest_dir / dest_name)
|
||||||
|
self.queue.enqueue(src, dest_dir, {"dest_name": dest_name}, dest_name, kind="psp-install")
|
||||||
|
return True
|
||||||
elif system == "ps1" or item.get("psp_kind") == "eboot":
|
elif system == "ps1" or item.get("psp_kind") == "eboot":
|
||||||
# PS1 EBOOTs and NoPayStation PSP games install as a whole game
|
# PS1 EBOOTs and NoPayStation PSP games install as a whole game
|
||||||
# folder under /PSP/GAME (optionally nested in a Category Lite
|
# folder under /PSP/GAME (optionally nested in a Category Lite
|
||||||
@@ -4674,6 +4831,25 @@ 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
|
||||||
|
psp_kind = item.get("psp_kind")
|
||||||
|
if psp_kind in ("theme", "dlc"):
|
||||||
|
name = Path(item.get("path") or "").name
|
||||||
|
target = (Path(self._psp_root) / "PSP" / "THEME" / name) if psp_kind == "theme" \
|
||||||
|
else (Path(self._psp_root) / "PSP" / "GAME" / Path(item.get("path") or "").parent.name / name)
|
||||||
|
if not target.exists():
|
||||||
|
QMessageBox.warning(self, "Not found", f"Could not find the installed copy on the PSP:\n{name}")
|
||||||
|
return
|
||||||
|
res = QMessageBox.question(
|
||||||
|
self, "Delete from PSP",
|
||||||
|
f"Delete this from the PSP?\n\n{name}\n\n{target}",
|
||||||
|
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
if res != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
if not transfers.remove_path(target):
|
||||||
|
QMessageBox.warning(self, "Delete incomplete", f"Could not delete:\n{target}")
|
||||||
|
self._refresh_psp()
|
||||||
|
return
|
||||||
if item.get("system") == "ps1" or item.get("psp_kind") == "eboot":
|
if item.get("system") == "ps1" or item.get("psp_kind") == "eboot":
|
||||||
# PS1 EBOOTs / NoPayStation PSP games: delete the whole game folder,
|
# PS1 EBOOTs / NoPayStation PSP games: delete the whole game folder,
|
||||||
# matched by the source folder id (or title).
|
# matched by the source folder id (or title).
|
||||||
|
|||||||
+29
-15
@@ -23,6 +23,9 @@ _miss_db = {}
|
|||||||
_miss_loaded = False
|
_miss_loaded = False
|
||||||
_MISS_TTL = 86400.0 # retry failed metadata after 1 day
|
_MISS_TTL = 86400.0 # retry failed metadata after 1 day
|
||||||
|
|
||||||
|
# Memoised release-date table (invalidated on save/reset).
|
||||||
|
_dates_cache = None
|
||||||
|
|
||||||
|
|
||||||
def _load_miss_db():
|
def _load_miss_db():
|
||||||
global _miss_loaded
|
global _miss_loaded
|
||||||
@@ -122,6 +125,7 @@ def save(release_id, system, meta, manual=False, title=None):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
_invalidate_dates_cache()
|
||||||
return True
|
return True
|
||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
return False
|
return False
|
||||||
@@ -140,6 +144,7 @@ def reset(release_id, system):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
_invalidate_dates_cache()
|
||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -161,23 +166,32 @@ def cached_release_dates():
|
|||||||
"""Return {(release_id, system): release_date} from cached metadata only.
|
"""Return {(release_id, system): release_date} from cached metadata only.
|
||||||
|
|
||||||
Pure DB read — no network — so list views can show a release date without
|
Pure DB read — no network — so list views can show a release date without
|
||||||
triggering API fetches for every row.
|
triggering API fetches for every row. Result is memoised and invalidated
|
||||||
|
whenever metadata is saved.
|
||||||
"""
|
"""
|
||||||
out = {}
|
global _dates_cache
|
||||||
try:
|
if _dates_cache is None:
|
||||||
conn = get_connection(config.DB_PATH)
|
out = {}
|
||||||
rows = conn.execute("SELECT release_id, system, meta_json FROM item_info").fetchall()
|
|
||||||
conn.close()
|
|
||||||
except sqlite3.Error:
|
|
||||||
return {}
|
|
||||||
for r in rows:
|
|
||||||
try:
|
try:
|
||||||
meta = json.loads(r["meta_json"] or "{}")
|
conn = get_connection(config.DB_PATH)
|
||||||
except (TypeError, ValueError):
|
rows = conn.execute("SELECT release_id, system, meta_json FROM item_info").fetchall()
|
||||||
continue
|
conn.close()
|
||||||
if isinstance(meta, dict) and meta.get("release_date"):
|
except sqlite3.Error:
|
||||||
out[(r["release_id"], r["system"])] = meta["release_date"]
|
return out
|
||||||
return out
|
for r in rows:
|
||||||
|
try:
|
||||||
|
meta = json.loads(r["meta_json"] or "{}")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if isinstance(meta, dict) and meta.get("release_date"):
|
||||||
|
out[(r["release_id"], r["system"])] = meta["release_date"]
|
||||||
|
_dates_cache = out
|
||||||
|
return _dates_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_dates_cache():
|
||||||
|
global _dates_cache
|
||||||
|
_dates_cache = None
|
||||||
|
|
||||||
|
|
||||||
def get_or_fetch(release_id, system, fetch_title):
|
def get_or_fetch(release_id, system, fetch_title):
|
||||||
|
|||||||
+43
-17
@@ -22,7 +22,12 @@ from . import config
|
|||||||
|
|
||||||
log = logging.getLogger("nopaystation")
|
log = logging.getLogger("nopaystation")
|
||||||
|
|
||||||
_TSV_NAMES = {"psp": "PSP_GAMES.tsv", "psx": "PSX_GAMES.tsv"}
|
_TSV_NAMES = {
|
||||||
|
"psp": "PSP_GAMES.tsv",
|
||||||
|
"psx": "PSX_GAMES.tsv",
|
||||||
|
"theme": "PSP_THEMES.tsv",
|
||||||
|
"dlc": "PSP_DLCS.tsv",
|
||||||
|
}
|
||||||
|
|
||||||
# NoPayStation TSVs are tab-separated with a header row whose exact columns
|
# NoPayStation TSVs are tab-separated with a header row whose exact columns
|
||||||
# differ per platform (PSP has "Type"/"RAP" columns, PSX has "Original Name").
|
# differ per platform (PSP has "Type"/"RAP" columns, PSX has "Original Name").
|
||||||
@@ -45,7 +50,7 @@ _HEADER_ALIASES = {
|
|||||||
|
|
||||||
|
|
||||||
def tsv_url(content_type: str, base: str = "") -> str:
|
def tsv_url(content_type: str, base: str = "") -> str:
|
||||||
"""TSV URL for a content type (``psp`` / ``psx``)."""
|
"""TSV URL for a content type (``psp`` / ``psx`` / ``theme`` / ``dlc``)."""
|
||||||
base = (base or config.NPS_TSV_BASE).rstrip("/")
|
base = (base or config.NPS_TSV_BASE).rstrip("/")
|
||||||
return f"{base}/{_TSV_NAMES.get(content_type, _TSV_NAMES['psp'])}"
|
return f"{base}/{_TSV_NAMES.get(content_type, _TSV_NAMES['psp'])}"
|
||||||
|
|
||||||
@@ -76,7 +81,7 @@ def parse_tsv(text: str, content_type: str) -> List[dict]:
|
|||||||
``release_date``, ``mtime_ns``) plus ``title_id``, ``pkg_url``, ``sha256``,
|
``release_date``, ``mtime_ns``) plus ``title_id``, ``pkg_url``, ``sha256``,
|
||||||
``content_type`` and the cover ``system`` ("games" for PSP, "ps1" for PSX).
|
``content_type`` and the cover ``system`` ("games" for PSP, "ps1" for PSX).
|
||||||
"""
|
"""
|
||||||
system = "games" if content_type == "psp" else "ps1"
|
system = {"psp": "games", "psx": "ps1", "theme": "theme", "dlc": "dlc"}.get(content_type, "games")
|
||||||
items: List[dict] = []
|
items: List[dict] = []
|
||||||
header = None
|
header = None
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
@@ -94,7 +99,9 @@ def parse_tsv(text: str, content_type: str) -> List[dict]:
|
|||||||
row[canon] = cols[i].strip()
|
row[canon] = cols[i].strip()
|
||||||
name = row.get("name") or ""
|
name = row.get("name") or ""
|
||||||
pkg_url = row.get("pkg_url") or ""
|
pkg_url = row.get("pkg_url") or ""
|
||||||
if not name or not pkg_url:
|
# NoPayStation marks delisted packages with the literal "MISSING" in the
|
||||||
|
# URL column — skip them so they can't be queued for download.
|
||||||
|
if not name or not pkg_url or not pkg_url.lower().startswith(("http://", "https://")):
|
||||||
continue
|
continue
|
||||||
title_id = row.get("title_id") or ""
|
title_id = row.get("title_id") or ""
|
||||||
try:
|
try:
|
||||||
@@ -124,22 +131,40 @@ def parse_tsv(text: str, content_type: str) -> List[dict]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def fetch_items(content_type: str, url: str = "", timeout: int = 60) -> List[dict]:
|
def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool = False) -> List[dict]:
|
||||||
"""Download (and cache) a TSV, returning parsed item dicts."""
|
"""Download (and cache) a TSV, returning parsed item dicts.
|
||||||
|
|
||||||
|
``force`` re-downloads the TSV (used by the Store's Refresh button); the
|
||||||
|
local cache is only used as a fallback when the network fails.
|
||||||
|
"""
|
||||||
url = url or tsv_url(content_type)
|
url = url or tsv_url(content_type)
|
||||||
name = _TSV_NAMES.get(content_type, "PSP_GAMES.tsv")
|
name = _TSV_NAMES.get(content_type, "PSP_GAMES.tsv")
|
||||||
cache = _tsv_dir() / name
|
cache = _tsv_dir() / name
|
||||||
if cache.is_file():
|
|
||||||
|
def read_cache():
|
||||||
try:
|
try:
|
||||||
return parse_tsv(cache.read_text(encoding="utf-8", errors="replace"), content_type)
|
return parse_tsv(cache.read_text(encoding="utf-8", errors="replace"), content_type)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
return None
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "psp-preserve/0.1"})
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
if not force and cache.is_file():
|
||||||
data = resp.read()
|
items = read_cache()
|
||||||
text = data.decode("utf-8", errors="replace")
|
if items is not None:
|
||||||
cache.write_text(text, encoding="utf-8")
|
return items
|
||||||
return parse_tsv(text, content_type)
|
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
except Exception: # noqa: BLE001 - fall back to the cached list on failure
|
||||||
|
items = read_cache()
|
||||||
|
if items is not None:
|
||||||
|
log.warning("could not refresh %s TSV; using cached copy", content_type)
|
||||||
|
return items
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def find_pkg2zip(configured_path: str = "") -> Optional[Path]:
|
def find_pkg2zip(configured_path: str = "") -> Optional[Path]:
|
||||||
@@ -187,7 +212,7 @@ def _ensure_keys_bin(folder, kind):
|
|||||||
|
|
||||||
|
|
||||||
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
||||||
progress_cb=None, cancel_event=None) -> Path:
|
kind_hint: str = None, progress_cb=None, cancel_event=None) -> Path:
|
||||||
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
|
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
|
||||||
|
|
||||||
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
|
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
|
||||||
@@ -199,12 +224,13 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
|||||||
|
|
||||||
pkg_path = Path(pkg_path)
|
pkg_path = Path(pkg_path)
|
||||||
dest = Path(dest_dir) / _safe_folder(title_id)
|
dest = Path(dest_dir) / _safe_folder(title_id)
|
||||||
if (dest / "EBOOT.PBP").exists():
|
if dest.is_dir() and any(dest.iterdir()):
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
# 1. Pure-Python decryptor (primary).
|
# 1. Pure-Python decryptor (primary).
|
||||||
try:
|
try:
|
||||||
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, progress_cb, cancel_event)
|
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
|
||||||
|
progress_cb, cancel_event)
|
||||||
_ensure_keys_bin(folder, _kind)
|
_ensure_keys_bin(folder, _kind)
|
||||||
return folder
|
return folder
|
||||||
except TransferCancelled:
|
except TransferCancelled:
|
||||||
|
|||||||
+151
-30
@@ -1,10 +1,19 @@
|
|||||||
"""Pure-Python PSP/PSX PSN ``.pkg`` decryption (no Wine / pkg2zip needed).
|
"""Pure-Python PSP/PSX/theme/DLC PSN ``.pkg`` decryption (no Wine / pkg2zip).
|
||||||
|
|
||||||
Ports the folder-mode extraction used by pkgi-psp's depackager and pkg2zip:
|
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
|
parse the ``\\x7FPKG`` header, derive the PSP/PS3 AES key, AES-CTR-decrypt the
|
||||||
entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder. The
|
file entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder.
|
||||||
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.
|
Supported content:
|
||||||
|
|
||||||
|
* PSP games / minis (``EBOOT.PBP``) — ``DATA.PSP`` stays EDAT-encrypted, so it
|
||||||
|
runs on CFW with ``npdrm_free`` (ARK ships it), same as pkgi-psp's install.
|
||||||
|
* PS1 Classics (``EBOOT.PBP`` + ``DOCUMENT.DAT``).
|
||||||
|
* PSP themes (``.ptf``) — the packaged PTF is EDAT-wrapped and is unwrapped here.
|
||||||
|
* PSP DLC (``.edat``) — extracted as-is into the owning game's folder.
|
||||||
|
|
||||||
|
Theme unwrapping needs ``pycryptodome`` (for AES-CMAC + ECB); everything else
|
||||||
|
works with either pycryptodome or the built-in pure-Python AES fallback.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,6 +25,14 @@ PKG_MAGIC = 0x7F504B47
|
|||||||
PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
|
PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
|
||||||
PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8")
|
PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8")
|
||||||
|
|
||||||
|
# PSP KIRK/AMCTL keys used to unwrap theme PTF EDATs (pkgi-psp / libkirk).
|
||||||
|
_KIRK38 = bytes.fromhex("12468d7e1c42209bba5426835eb03303")
|
||||||
|
_KIRK39 = bytes.fromhex("c43bb6d653ee67493ea95fbc0ced6f8a")
|
||||||
|
_KIRK63 = bytes.fromhex("9c9b1372f8c640cf1c62f5d592ddb582")
|
||||||
|
_AMCTL3 = bytes.fromhex("e350ed1d910a1fd029bb1c3ef34077fb")
|
||||||
|
_AMCTL4 = bytes.fromhex("135fa47cab395ba476b8cca98f3a0445")
|
||||||
|
_AMCTL5 = bytes.fromhex("678d7fa32a9ca0d1508ad8385e4b017e")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from Crypto.Cipher import AES as _AES # pycryptodome (fast)
|
from Crypto.Cipher import AES as _AES # pycryptodome (fast)
|
||||||
_HAVE_CRYPTO = True
|
_HAVE_CRYPTO = True
|
||||||
@@ -136,13 +153,86 @@ def _make_ctr_decryptor(key, iv, block_index):
|
|||||||
return decrypt
|
return decrypt
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# PSP theme PTF EDAT unwrapping (pkgi-psp's unpack_psp_edat)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _need_crypto():
|
||||||
|
if not _HAVE_CRYPTO:
|
||||||
|
raise RuntimeError("theme extraction needs pycryptodome (pip install pycryptodome)")
|
||||||
|
|
||||||
|
|
||||||
|
def _ecb_decrypt(key, block):
|
||||||
|
_need_crypto()
|
||||||
|
return _AES.new(key, _AES.MODE_ECB).decrypt(block)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmac(key, data):
|
||||||
|
_need_crypto()
|
||||||
|
from Crypto.Hash import CMAC
|
||||||
|
c = CMAC.new(key, ciphermod=_AES)
|
||||||
|
c.update(data)
|
||||||
|
return c.digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _kirk_iv(mac, key_header, offset2):
|
||||||
|
tmp = _ecb_decrypt(_KIRK38, bytes(key_header[0x70:0x80]))
|
||||||
|
iv = bytes(mac[i] ^ tmp[i] ^ key_header[offset2 + i] ^ _AMCTL3[i] ^ _AMCTL5[i] for i in range(16))
|
||||||
|
iv = _ecb_decrypt(_KIRK39, iv)
|
||||||
|
return bytes(b ^ _AMCTL4[i] for i, b in enumerate(iv))
|
||||||
|
|
||||||
|
|
||||||
|
def _kirk_cbc_decrypt(iv, index, data):
|
||||||
|
prev = bytearray(16) if index == 0 else bytearray(iv[:12]) + index.to_bytes(4, "little")
|
||||||
|
block = bytearray(iv[:12]) + index.to_bytes(4, "little")
|
||||||
|
out = bytearray()
|
||||||
|
for i in range(0, len(data), 16):
|
||||||
|
v = int.from_bytes(block[12:16], "little") + 1
|
||||||
|
block[12:16] = v.to_bytes(4, "little")
|
||||||
|
o = _ecb_decrypt(_KIRK63, bytes(block))
|
||||||
|
out += bytes(b ^ prev[k] ^ o[k] for k, b in enumerate(data[i:i + 16]))
|
||||||
|
prev = bytes(block)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_theme_ptf(buf):
|
||||||
|
"""Unwrap a PSP theme PTF EDAT (``buf`` is the fully CTR-decrypted entry)."""
|
||||||
|
if len(buf) < 0x90 + 0xA0:
|
||||||
|
raise RuntimeError("theme EDAT is too short")
|
||||||
|
kh_off = buf[0xC]
|
||||||
|
key_header = bytearray(buf[kh_off:kh_off + 0xA0])
|
||||||
|
if bytes(key_header[:4]) != b"\x00PGD":
|
||||||
|
raise RuntimeError("theme EDAT: bad PGD header")
|
||||||
|
if int.from_bytes(key_header[4:8], "little") != 1 or int.from_bytes(key_header[8:12], "little") != 1:
|
||||||
|
raise RuntimeError("theme EDAT: unsupported key/drm type")
|
||||||
|
|
||||||
|
mac = _cmac(_KIRK38, bytes(key_header[:0x70]))
|
||||||
|
|
||||||
|
# Step 1: reveal the data size/offset fields (bytes 0x30..0x60).
|
||||||
|
key_header[0x30:0x60] = _kirk_cbc_decrypt(_kirk_iv(mac, key_header, 0x10), 0, bytes(key_header[0x30:0x60]))
|
||||||
|
data_size = int.from_bytes(key_header[0x44:0x48], "little")
|
||||||
|
data_offset = int.from_bytes(key_header[0x4c:0x50], "little")
|
||||||
|
if data_offset != 0x90:
|
||||||
|
raise RuntimeError("theme EDAT: unexpected data offset")
|
||||||
|
|
||||||
|
# Step 2: decrypt the PTF body.
|
||||||
|
iv = _kirk_iv(mac, key_header, 0x30)
|
||||||
|
out = bytearray()
|
||||||
|
nblocks = (data_size + 15) // 16
|
||||||
|
for i in range(nblocks):
|
||||||
|
chunk = buf[kh_off + data_offset + i * 16:kh_off + data_offset + (i + 1) * 16]
|
||||||
|
out += _kirk_cbc_decrypt(iv, i, chunk)
|
||||||
|
return bytes(out[:data_size])
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# PKG extraction
|
# PKG extraction
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
def extract(pkg_path, dest_dir, kind_hint=None, progress_cb=None, cancel_event=None):
|
||||||
"""Decrypt a PSP/PSX PSN pkg into ``dest_dir/<TITLE_ID>/``.
|
"""Decrypt a PSN pkg into ``dest_dir/<TITLE_ID>/``.
|
||||||
|
|
||||||
Returns ``(kind, title_id, folder)`` where kind is ``"psp"`` or ``"psx"``.
|
Returns ``(kind, title_id, folder)`` where kind is ``"psp"``, ``"psx"``,
|
||||||
|
``"theme"`` or ``"dlc"``. ``kind_hint`` ("theme"/"dlc") overrides the
|
||||||
|
pkg-header content-type detection (NoPayStation DLC ships as two formats).
|
||||||
"""
|
"""
|
||||||
from .store import TransferCancelled
|
from .store import TransferCancelled
|
||||||
|
|
||||||
@@ -179,19 +269,30 @@ def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
|||||||
items_offset = int.from_bytes(m[8:12], "big")
|
items_offset = int.from_bytes(m[8:12], "big")
|
||||||
off += 8 + msize
|
off += 8 + msize
|
||||||
|
|
||||||
if content_type == 6:
|
if kind_hint in ("theme", "dlc"):
|
||||||
|
kind = kind_hint
|
||||||
|
elif content_type == 6:
|
||||||
kind = "psx"
|
kind = "psx"
|
||||||
elif content_type in (7, 0xE, 0xF, 0x10):
|
elif content_type in (7, 0xE, 0xF, 0x10):
|
||||||
kind = "psp"
|
kind = "psp"
|
||||||
|
elif content_type == 9:
|
||||||
|
kind = "theme"
|
||||||
|
elif content_type == 0xB:
|
||||||
|
kind = "dlc"
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}")
|
raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}")
|
||||||
|
|
||||||
if key_type != 1:
|
# NoPayStation DLC is wrapped in a PS3-format pkg (key type 4 in the header
|
||||||
|
# but actually encrypted with the PS3 key); everything else uses the PSP key.
|
||||||
|
if content_type == 0xB:
|
||||||
|
main_key = PS3_KEY
|
||||||
|
ps3_key = PS3_KEY
|
||||||
|
elif key_type == 1:
|
||||||
|
main_key = PSP_KEY
|
||||||
|
ps3_key = PS3_KEY
|
||||||
|
else:
|
||||||
raise RuntimeError(f"unsupported PKG key type {key_type}")
|
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.
|
# Decrypt the whole item table in one shot.
|
||||||
with open(pkg_path, "rb") as fh:
|
with open(pkg_path, "rb") as fh:
|
||||||
fh.seek(enc_offset + items_offset)
|
fh.seek(enc_offset + items_offset)
|
||||||
@@ -232,26 +333,46 @@ def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
|||||||
continue
|
continue
|
||||||
if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"):
|
if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"):
|
||||||
continue
|
continue
|
||||||
|
if kind == "theme" and not base.lower().endswith(".ptf"):
|
||||||
|
continue
|
||||||
|
if kind == "dlc" and not base.lower().endswith(".edat"):
|
||||||
|
continue
|
||||||
|
if kind == "dlc" and base.upper() == "ISO.BIN.EDAT":
|
||||||
|
# Small EDAT wrapper shipped inside PSP DLC packages — not DLC
|
||||||
|
# content itself, so don't install it (matches pkgi-psp).
|
||||||
|
continue
|
||||||
|
|
||||||
dest = folder / base
|
if kind == "theme":
|
||||||
decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16)
|
# Read the (small) entry fully, CTR-decrypt, then unwrap the EDAT.
|
||||||
fh.seek(enc_offset + data_offset)
|
fh.seek(enc_offset + data_offset)
|
||||||
with open(dest, "wb") as out:
|
raw = _ctr_decrypt(item_key, iv, data_offset // 16, fh.read(data_size))
|
||||||
remaining = data_size
|
dest = folder / base
|
||||||
while remaining > 0:
|
dest.write_bytes(_unwrap_theme_ptf(raw))
|
||||||
if cancel_event is not None and cancel_event.is_set():
|
done += data_size
|
||||||
raise TransferCancelled()
|
else:
|
||||||
chunk = fh.read(min(1 << 20, remaining))
|
dest = folder / base
|
||||||
if not chunk:
|
decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16)
|
||||||
break
|
fh.seek(enc_offset + data_offset)
|
||||||
out.write(decrypt(chunk))
|
with open(dest, "wb") as out:
|
||||||
remaining -= len(chunk)
|
remaining = data_size
|
||||||
done += len(chunk)
|
while remaining > 0:
|
||||||
if progress_cb is not None:
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
progress_cb("Extracting", min(done / total, 1.0), "")
|
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)
|
written.append(dest)
|
||||||
|
|
||||||
if not (folder / "EBOOT.PBP").exists():
|
if kind in ("psp", "psx") and not (folder / "EBOOT.PBP").exists():
|
||||||
raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}")
|
raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}")
|
||||||
|
if kind == "theme" and not any(p.suffix.lower() == ".ptf" for p in written):
|
||||||
|
raise RuntimeError(f"no PTF found in {pkg_path.name}")
|
||||||
|
if kind == "dlc" and not written:
|
||||||
|
raise RuntimeError(f"no DLC EDAT found in {pkg_path.name}")
|
||||||
|
|
||||||
return kind, title_id, folder
|
return kind, title_id, folder
|
||||||
|
|||||||
+43
-1
@@ -221,8 +221,48 @@ def _pkg_items(root):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _theme_items(root):
|
||||||
|
"""Installed themes (``PSP/THEME/*.ptf``), named from the PTF header."""
|
||||||
|
items = []
|
||||||
|
theme_dir = root / "PSP" / "THEME"
|
||||||
|
if theme_dir.is_dir():
|
||||||
|
try:
|
||||||
|
entries = sorted(theme_dir.iterdir())
|
||||||
|
except OSError:
|
||||||
|
return items
|
||||||
|
for path in entries:
|
||||||
|
if not path.is_file() or path.suffix.lower() != ".ptf":
|
||||||
|
continue
|
||||||
|
item = _file_item(path, "theme")
|
||||||
|
try:
|
||||||
|
from . import theme as theme_mod
|
||||||
|
name = theme_mod.ptf_name(path)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
name = ""
|
||||||
|
if name:
|
||||||
|
item["name"] = name
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _dlc_items(root):
|
||||||
|
"""Installed DLC (``PSP/GAME/<GAME_ID>/*.edat``)."""
|
||||||
|
items = []
|
||||||
|
game_dir = root / "PSP" / "GAME"
|
||||||
|
if game_dir.is_dir():
|
||||||
|
for path in sorted(game_dir.rglob("*")):
|
||||||
|
if not path.is_file() or path.suffix.lower() != ".edat":
|
||||||
|
continue
|
||||||
|
if path.name.upper() == "ISO.BIN.EDAT":
|
||||||
|
continue # package wrapper, not DLC content
|
||||||
|
item = _file_item(path, "dlc")
|
||||||
|
item["id"] = path.parent.name
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
def list_installed(root):
|
def list_installed(root):
|
||||||
"""Return ``{games, videos, homebrew, pkg}`` inventories for a PSP root."""
|
"""Return ``{games, videos, homebrew, pkg, themes, dlc}`` for a PSP root."""
|
||||||
root = Path(root)
|
root = Path(root)
|
||||||
games, videos, homebrew_isos = _iso_items(root)
|
games, videos, homebrew_isos = _iso_items(root)
|
||||||
return {
|
return {
|
||||||
@@ -230,6 +270,8 @@ def list_installed(root):
|
|||||||
"videos": videos,
|
"videos": videos,
|
||||||
"homebrew": _homebrew_items(root) + homebrew_isos,
|
"homebrew": _homebrew_items(root) + homebrew_isos,
|
||||||
"pkg": _pkg_items(root),
|
"pkg": _pkg_items(root),
|
||||||
|
"themes": _theme_items(root),
|
||||||
|
"dlc": _dlc_items(root),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+511
@@ -0,0 +1,511 @@
|
|||||||
|
"""PSP theme (``.ptf``) introspection: name + preview image.
|
||||||
|
|
||||||
|
PTF themes embed a readable name in their header and a preview image (a GIM
|
||||||
|
bitmap) in object 0. This module decodes both without any external service:
|
||||||
|
|
||||||
|
* the name lives at offset 0x08 (128 UTF-8 bytes);
|
||||||
|
* the preview is object 0's largest image item, stored as a GIM that may be
|
||||||
|
Sony-LZR compressed.
|
||||||
|
|
||||||
|
GIM decoding covers the common RGBA formats (5650/5551/4444/8888) and 8-bit
|
||||||
|
palettes; anything exotic simply yields no preview.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PTF_MAGIC = b"\x00PTF"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Sony LZR/RLZ decompression (sceLzrDecompress), ported from PTF-Studio.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_LZR_PROBABILITY_SLOTS = 2800
|
||||||
|
_LZR_INITIAL_PROBABILITY = 0x80
|
||||||
|
_LZR_RENORM_LIMIT = 0x00FFFFFF
|
||||||
|
_LZR_U32_MOD = 0x100000000
|
||||||
|
|
||||||
|
|
||||||
|
def _lzr_decompress(inp, max_output, offset=0):
|
||||||
|
inp = bytes(inp)
|
||||||
|
end = offset + len(inp) - offset
|
||||||
|
out = bytearray(max_output)
|
||||||
|
probs = [_LZR_INITIAL_PROBABILITY] * _LZR_PROBABILITY_SLOTS
|
||||||
|
out_pos = 0
|
||||||
|
in_pos = offset + 5
|
||||||
|
code = int.from_bytes(inp[offset + 1:offset + 5], "big")
|
||||||
|
range_ = 0xFFFFFFFF
|
||||||
|
spec = 0
|
||||||
|
corrupt = False
|
||||||
|
buf_off = 0
|
||||||
|
last_byte = 0
|
||||||
|
last_number_flag = 0
|
||||||
|
type_ = inp[offset]
|
||||||
|
if type_ >= 0x80:
|
||||||
|
type_ -= 0x100
|
||||||
|
|
||||||
|
def next_byte():
|
||||||
|
nonlocal in_pos, corrupt
|
||||||
|
if in_pos >= end:
|
||||||
|
corrupt = True
|
||||||
|
return 0
|
||||||
|
b = inp[in_pos]
|
||||||
|
in_pos += 1
|
||||||
|
return b
|
||||||
|
|
||||||
|
if type_ > 7:
|
||||||
|
raise ValueError("unsupported LZR literal-context mode")
|
||||||
|
if type_ < 0:
|
||||||
|
stored = code
|
||||||
|
if stored > len(out) or in_pos + stored > end:
|
||||||
|
raise ValueError("malformed stored LZR block")
|
||||||
|
return inp[in_pos:in_pos + stored]
|
||||||
|
|
||||||
|
def renormalize():
|
||||||
|
nonlocal code, range_
|
||||||
|
if range_ <= _LZR_RENORM_LIMIT:
|
||||||
|
code = (code * 256 + next_byte()) % _LZR_U32_MOD
|
||||||
|
range_ = (range_ * 256) % _LZR_U32_MOD
|
||||||
|
|
||||||
|
def decode_bit(prob_index):
|
||||||
|
nonlocal code, range_, corrupt
|
||||||
|
if prob_index < 0 or prob_index >= _LZR_PROBABILITY_SLOTS:
|
||||||
|
corrupt = True
|
||||||
|
return 0
|
||||||
|
renormalize()
|
||||||
|
if corrupt:
|
||||||
|
return 0
|
||||||
|
p = probs[prob_index]
|
||||||
|
bound = (range_ // 256) * p
|
||||||
|
probs[prob_index] = p - (p // 8)
|
||||||
|
if code < bound:
|
||||||
|
range_ = bound
|
||||||
|
probs[prob_index] += 31
|
||||||
|
return 1
|
||||||
|
code -= bound
|
||||||
|
range_ -= bound
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def decode_bit_speculative(prob_index):
|
||||||
|
nonlocal code, range_, spec, corrupt
|
||||||
|
if prob_index < 0 or prob_index >= _LZR_PROBABILITY_SLOTS:
|
||||||
|
corrupt = True
|
||||||
|
return 0
|
||||||
|
if spec <= _LZR_RENORM_LIMIT:
|
||||||
|
code = (code * 256 + next_byte()) % _LZR_U32_MOD
|
||||||
|
range_ = (spec * 256) % _LZR_U32_MOD
|
||||||
|
if corrupt:
|
||||||
|
return 0
|
||||||
|
p = probs[prob_index]
|
||||||
|
bound = (range_ // 256) * p
|
||||||
|
spec = bound
|
||||||
|
probs[prob_index] = p - (p // 8)
|
||||||
|
if code < bound:
|
||||||
|
range_ = bound
|
||||||
|
probs[prob_index] += 31
|
||||||
|
return 1
|
||||||
|
code -= bound
|
||||||
|
range_ -= bound
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def decode_number(bits, base, step):
|
||||||
|
nonlocal code, range_, corrupt, last_number_flag
|
||||||
|
number = 1
|
||||||
|
if bits >= 3:
|
||||||
|
number = number * 2 + decode_bit(base + 3 * step)
|
||||||
|
if bits >= 4:
|
||||||
|
number = number * 2 + decode_bit(base + 3 * step)
|
||||||
|
if bits >= 5:
|
||||||
|
renormalize()
|
||||||
|
remaining = bits
|
||||||
|
while remaining >= 5:
|
||||||
|
if range_ == 0:
|
||||||
|
corrupt = True
|
||||||
|
return 0
|
||||||
|
number *= 2
|
||||||
|
range_ = range_ // 2
|
||||||
|
if code < range_:
|
||||||
|
number += 1
|
||||||
|
else:
|
||||||
|
code -= range_
|
||||||
|
remaining -= 1
|
||||||
|
last_number_flag = decode_bit(base)
|
||||||
|
number = number * 2 + last_number_flag
|
||||||
|
if bits >= 1:
|
||||||
|
number = number * 2 + decode_bit(base + step)
|
||||||
|
if bits >= 2:
|
||||||
|
number = number * 2 + decode_bit(base + 2 * step)
|
||||||
|
return number
|
||||||
|
|
||||||
|
def selector_context():
|
||||||
|
return buf_off + 2488
|
||||||
|
|
||||||
|
def decode_literal(shift):
|
||||||
|
nonlocal out_pos, last_byte, corrupt, buf_off
|
||||||
|
if buf_off > 0:
|
||||||
|
buf_off -= 1
|
||||||
|
if out_pos == len(out):
|
||||||
|
return False
|
||||||
|
context = ((((out_pos & 7) << 8) + last_byte) // (2 ** shift)) & 7
|
||||||
|
tree_base = context * 0xFF - 1
|
||||||
|
node = 1
|
||||||
|
while node <= 0xFF:
|
||||||
|
node = node * 2 + decode_bit(tree_base + node)
|
||||||
|
if corrupt:
|
||||||
|
return False
|
||||||
|
out[out_pos] = node & 0xFF
|
||||||
|
last_byte = node & 0xFF
|
||||||
|
out_pos += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def decode_match():
|
||||||
|
nonlocal out_pos, last_byte, buf_off, spec, code, range_, corrupt, last_number_flag
|
||||||
|
slot = selector_context()
|
||||||
|
spec = range_
|
||||||
|
length_class = -1
|
||||||
|
bit = 0
|
||||||
|
while True:
|
||||||
|
slot += 8
|
||||||
|
bit = decode_bit_speculative(slot)
|
||||||
|
if corrupt:
|
||||||
|
return -1
|
||||||
|
length_class += bit
|
||||||
|
if bit == 0 or length_class >= 6:
|
||||||
|
break
|
||||||
|
offset_group = length_class + 2033
|
||||||
|
offset_bias = 64
|
||||||
|
if bit != 0 or length_class >= 0:
|
||||||
|
length_context = (length_class << 5) + (((out_pos * (2 ** length_class)) & 3) << 3) + buf_off + 2552
|
||||||
|
match_length = decode_number(length_class, length_context, 8)
|
||||||
|
if corrupt:
|
||||||
|
return -1
|
||||||
|
if match_length == 0xFF:
|
||||||
|
return 1
|
||||||
|
if last_number_flag != 0 or length_class > 0:
|
||||||
|
offset_group += 56
|
||||||
|
offset_bias = 352
|
||||||
|
else:
|
||||||
|
match_length = 1
|
||||||
|
index = 1
|
||||||
|
offset_class = 0
|
||||||
|
while True:
|
||||||
|
offset_class = (index << 4) - offset_bias
|
||||||
|
prob_index = offset_group + (index << 3)
|
||||||
|
if prob_index >= _LZR_PROBABILITY_SLOTS:
|
||||||
|
return -1
|
||||||
|
bit = decode_bit(prob_index)
|
||||||
|
if corrupt:
|
||||||
|
return -1
|
||||||
|
index = (index << 1) | bit
|
||||||
|
if offset_class >= 0:
|
||||||
|
break
|
||||||
|
if bit != 0 or offset_class > 0:
|
||||||
|
if bit == 0:
|
||||||
|
offset_class -= 8
|
||||||
|
base = offset_class + 2344
|
||||||
|
if base < 0 or base + 3 >= _LZR_PROBABILITY_SLOTS:
|
||||||
|
return -1
|
||||||
|
back_offset = decode_number(offset_class // 8, base, 1)
|
||||||
|
if corrupt:
|
||||||
|
return -1
|
||||||
|
else:
|
||||||
|
back_offset = 1
|
||||||
|
if back_offset > out_pos:
|
||||||
|
return -1
|
||||||
|
copy_end = out_pos + match_length + 1
|
||||||
|
if copy_end > len(out):
|
||||||
|
return -1
|
||||||
|
buf_off = ((copy_end + 1) & 1) + 6
|
||||||
|
from_ = out_pos - back_offset
|
||||||
|
while out_pos < copy_end:
|
||||||
|
out[out_pos] = out[from_]
|
||||||
|
out_pos += 1
|
||||||
|
from_ += 1
|
||||||
|
last_byte = out[out_pos - 1]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
token_budget = max(1024, max_output * 2 + 1024)
|
||||||
|
while True:
|
||||||
|
token_budget -= 1
|
||||||
|
if token_budget < 0:
|
||||||
|
raise ValueError("malformed LZR stream did not terminate")
|
||||||
|
token_is_match = decode_bit(selector_context())
|
||||||
|
if corrupt:
|
||||||
|
raise ValueError("truncated LZR stream")
|
||||||
|
if token_is_match == 0:
|
||||||
|
if not decode_literal(type_):
|
||||||
|
raise ValueError("LZR stream exceeds declared output size")
|
||||||
|
else:
|
||||||
|
result = decode_match()
|
||||||
|
if result == 1:
|
||||||
|
return bytes(out[:out_pos])
|
||||||
|
if result < 0:
|
||||||
|
raise ValueError("malformed LZR back-reference")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# PTF parsing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _decompress_item(blob, comp, ucsize):
|
||||||
|
if comp == 2 and len(blob) < ucsize:
|
||||||
|
return zlib.decompress(blob)
|
||||||
|
if comp == 1 and len(blob) < ucsize:
|
||||||
|
return _lzr_decompress(blob, ucsize)
|
||||||
|
return blob
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ptf(data):
|
||||||
|
"""Return ``(name, items)`` where items are decompressed item blobs."""
|
||||||
|
if not isinstance(data, (bytes, bytearray)):
|
||||||
|
data = data.read_bytes() if hasattr(data, "read_bytes") else bytes(data)
|
||||||
|
data = bytes(data)
|
||||||
|
if data[:4] != PTF_MAGIC:
|
||||||
|
raise ValueError("not a PTF file")
|
||||||
|
|
||||||
|
raw_name = data[8:8 + 128]
|
||||||
|
name = raw_name.split(b"\x00", 1)[0].decode("utf-8", "replace").strip()
|
||||||
|
|
||||||
|
def u16(o):
|
||||||
|
return int.from_bytes(data[o:o + 2], "little")
|
||||||
|
|
||||||
|
def u32(o):
|
||||||
|
return int.from_bytes(data[o:o + 4], "little")
|
||||||
|
|
||||||
|
items = []
|
||||||
|
pos = 0x100
|
||||||
|
while True:
|
||||||
|
offset = u32(pos)
|
||||||
|
pos += 4
|
||||||
|
if offset == 0 or offset >= len(data):
|
||||||
|
break
|
||||||
|
count = u16(offset + 2)
|
||||||
|
data_off = u32(offset + 8)
|
||||||
|
p = data_off
|
||||||
|
for _ in range(count):
|
||||||
|
file_type = u16(p + 4)
|
||||||
|
comp = u16(p + 6)
|
||||||
|
size = u32(p + 8)
|
||||||
|
ucsize = u32(p + 12)
|
||||||
|
blob = data[p + 0x20:p + 0x20 + size]
|
||||||
|
items.append((file_type, _decompress_item(blob, comp, ucsize)))
|
||||||
|
p += 0x20 + size
|
||||||
|
return name, items
|
||||||
|
|
||||||
|
|
||||||
|
def ptf_name(path):
|
||||||
|
"""Theme display name embedded in a ``.ptf`` file."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
head = fh.read(8 + 128)
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
if head[:4] != PTF_MAGIC:
|
||||||
|
return ""
|
||||||
|
name = head[8:8 + 128].split(b"\x00", 1)[0].decode("utf-8", "replace").strip()
|
||||||
|
return name or Path(path).stem
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# GIM decoding (RGBA formats only)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _overscan(value, tile):
|
||||||
|
return value if value % tile == 0 else value + (tile - (value % tile))
|
||||||
|
|
||||||
|
|
||||||
|
def _untile(pixels, w, h, tile_w, tile_h):
|
||||||
|
over_w = _overscan(w, tile_w)
|
||||||
|
over_h = _overscan(h, tile_h)
|
||||||
|
out = [0] * (w * h)
|
||||||
|
tile_pos = 0
|
||||||
|
origin_x = 0
|
||||||
|
origin_y = 0
|
||||||
|
for y in range(over_h):
|
||||||
|
for x in range(over_w):
|
||||||
|
dx = origin_x + (tile_pos % tile_w)
|
||||||
|
dy = origin_y + (tile_pos // tile_w)
|
||||||
|
if dx < w and dy < h:
|
||||||
|
out[dy * w + dx] = pixels[y * over_w + x]
|
||||||
|
tile_pos += 1
|
||||||
|
if tile_pos == tile_w * tile_h:
|
||||||
|
tile_pos = 0
|
||||||
|
origin_x += tile_w
|
||||||
|
if origin_x >= over_w:
|
||||||
|
origin_x = 0
|
||||||
|
origin_y += tile_h
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _gim_to_rgba(data):
|
||||||
|
"""Decode a GIM to ``(width, height, rgba_bytes)`` or None if unsupported."""
|
||||||
|
data = bytes(data)
|
||||||
|
if len(data) < 0x10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Detect byte order from the magic. Sony GIMs store the magic as the byte
|
||||||
|
# string "MIG.00.1PSP" with little-endian data (LibPSPThemes semantics).
|
||||||
|
if data[:4] == b"MIG.":
|
||||||
|
byteorder = "little"
|
||||||
|
elif data[:4] == b".GIM":
|
||||||
|
byteorder = "big"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def rd(off, size):
|
||||||
|
return int.from_bytes(data[off:off + size], byteorder)
|
||||||
|
|
||||||
|
image = None
|
||||||
|
palette = None
|
||||||
|
pos = 0x10
|
||||||
|
for _ in range(32):
|
||||||
|
if pos + 16 > len(data):
|
||||||
|
break
|
||||||
|
btype = rd(pos, 2)
|
||||||
|
next_block = rd(pos + 8, 4)
|
||||||
|
if btype == 4 or btype == 5:
|
||||||
|
h = pos + 0x10
|
||||||
|
fmt = rd(h + 4, 2)
|
||||||
|
pixel_order = rd(h + 6, 2)
|
||||||
|
width = rd(h + 8, 2)
|
||||||
|
height = rd(h + 10, 2)
|
||||||
|
bpp = rd(h + 12, 2)
|
||||||
|
next_index = rd(h + 24, 4)
|
||||||
|
frame_data_end = rd(h + 32, 4)
|
||||||
|
frame_offset = rd(pos + 0x10 + next_index, 4)
|
||||||
|
block_content = pos + 0x10
|
||||||
|
start = block_content + frame_offset
|
||||||
|
end = block_content + frame_data_end
|
||||||
|
raw = data[start:end]
|
||||||
|
pixels = _decode_pixels(raw, fmt, bpp, width, height, pixel_order)
|
||||||
|
if pixels is None:
|
||||||
|
return None
|
||||||
|
if btype == 4:
|
||||||
|
image = (width, height, pixels)
|
||||||
|
else:
|
||||||
|
palette = (fmt, width, height, pixels)
|
||||||
|
if next_block == 0:
|
||||||
|
break
|
||||||
|
pos += next_block
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
return None
|
||||||
|
width, height, pixels = image
|
||||||
|
if palette is not None:
|
||||||
|
_, pw, ph, pvals = palette
|
||||||
|
if pw * ph > 0:
|
||||||
|
pixels = [pvals[p] if p < len(pvals) else 0 for p in pixels]
|
||||||
|
|
||||||
|
rgba = bytearray(width * height * 4)
|
||||||
|
for i, v in enumerate(pixels):
|
||||||
|
# pixels are packed 0xAABBGGRR
|
||||||
|
o = i * 4
|
||||||
|
rgba[o] = v & 0xFF
|
||||||
|
rgba[o + 1] = (v >> 8) & 0xFF
|
||||||
|
rgba[o + 2] = (v >> 16) & 0xFF
|
||||||
|
rgba[o + 3] = (v >> 24) & 0xFF
|
||||||
|
return width, height, bytes(rgba)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_pixels(raw, fmt, bpp, width, height, pixel_order):
|
||||||
|
"""Decode a GIM frame into a list of packed 0xAABBGGRR ints."""
|
||||||
|
if fmt in (0, 1, 2, 3, 5):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return None # indexed-16 / DXT etc. not supported
|
||||||
|
|
||||||
|
if fmt == 5: # p8 indexed
|
||||||
|
bytes_per_pixel = 1
|
||||||
|
tile_w = 0x80 // max(bpp, 8)
|
||||||
|
else:
|
||||||
|
bytes_per_pixel = max(1, (bpp + 7) // 8)
|
||||||
|
tile_w = 0x80 // max(bpp, 1)
|
||||||
|
tile_h = 8
|
||||||
|
|
||||||
|
if pixel_order == 1:
|
||||||
|
w = _overscan(width, tile_w)
|
||||||
|
h = _overscan(height, tile_h)
|
||||||
|
else:
|
||||||
|
w, h = width, height
|
||||||
|
|
||||||
|
count = w * h
|
||||||
|
values = []
|
||||||
|
pos = 0
|
||||||
|
for _ in range(count):
|
||||||
|
v = int.from_bytes(raw[pos:pos + bytes_per_pixel], "little")
|
||||||
|
pos += bytes_per_pixel
|
||||||
|
values.append(_expand(fmt, bpp, v))
|
||||||
|
if pos >= len(raw):
|
||||||
|
# pad missing trailing pixels with transparent
|
||||||
|
values.extend([0] * (count - len(values)))
|
||||||
|
break
|
||||||
|
|
||||||
|
if pixel_order == 1:
|
||||||
|
values = _untile(values, width, height, tile_w, tile_h)
|
||||||
|
return values[:width * height]
|
||||||
|
|
||||||
|
|
||||||
|
def _expand(fmt, bpp, v):
|
||||||
|
"""Expand a raw GIM pixel value into packed 0xAABBGGRR."""
|
||||||
|
if fmt == 3: # rgba8888
|
||||||
|
r = v & 0xFF
|
||||||
|
g = (v >> 8) & 0xFF
|
||||||
|
b = (v >> 16) & 0xFF
|
||||||
|
a = (v >> 24) & 0xFF
|
||||||
|
return (a << 24) | (b << 16) | (g << 8) | r
|
||||||
|
if fmt == 0: # rgba5650
|
||||||
|
r = (v >> 11) & 0x1F
|
||||||
|
g = (v >> 5) & 0x3F
|
||||||
|
b = v & 0x1F
|
||||||
|
r = (r << 3) | (r >> 2)
|
||||||
|
g = (g << 2) | (g >> 4)
|
||||||
|
b = (b << 3) | (b >> 2)
|
||||||
|
return (0xFF << 24) | (b << 16) | (g << 8) | r
|
||||||
|
if fmt == 1: # rgba5551
|
||||||
|
r = (v >> 11) & 0x1F
|
||||||
|
g = (v >> 6) & 0x1F
|
||||||
|
b = (v >> 1) & 0x1F
|
||||||
|
a = 0xFF if v & 1 else 0
|
||||||
|
r = (r << 3) | (r >> 2)
|
||||||
|
g = (g << 3) | (g >> 2)
|
||||||
|
b = (b << 3) | (b >> 2)
|
||||||
|
return (a << 24) | (b << 16) | (g << 8) | r
|
||||||
|
if fmt == 2: # rgba4444
|
||||||
|
r = (v >> 12) & 0xF
|
||||||
|
g = (v >> 8) & 0xF
|
||||||
|
b = (v >> 4) & 0xF
|
||||||
|
a = v & 0xF
|
||||||
|
r = (r << 4) | r
|
||||||
|
g = (g << 4) | g
|
||||||
|
b = (b << 4) | b
|
||||||
|
a = (a << 4) | a
|
||||||
|
return (a << 24) | (b << 16) | (g << 8) | r
|
||||||
|
if fmt == 5: # p8 — value is a palette index (expanded later)
|
||||||
|
return v & 0xFF
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Public API
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def theme_preview_rgba(ptf_path):
|
||||||
|
"""Decode the preview image of a theme PTF.
|
||||||
|
|
||||||
|
Returns ``(width, height, rgba_bytes)`` or None if it can't be decoded.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = Path(ptf_path).read_bytes()
|
||||||
|
_, items = parse_ptf(data)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# The preview image is the largest decodable GIM item.
|
||||||
|
best = None
|
||||||
|
for file_type, blob in items:
|
||||||
|
if file_type != 5:
|
||||||
|
continue
|
||||||
|
decoded = _gim_to_rgba(blob)
|
||||||
|
if decoded is None:
|
||||||
|
continue
|
||||||
|
w, h, rgba = decoded
|
||||||
|
if best is None or w * h > best[0] * best[1]:
|
||||||
|
best = (w, h, rgba)
|
||||||
|
return best
|
||||||
+24
-8
@@ -468,30 +468,33 @@ class LocalShareSource(Source):
|
|||||||
class NoPayStationSource(Source):
|
class NoPayStationSource(Source):
|
||||||
"""Browse NoPayStation .pkg lists and install PSP/PSX PSN content.
|
"""Browse NoPayStation .pkg lists and install PSP/PSX PSN content.
|
||||||
|
|
||||||
``content_type`` is ``"psp"`` (PSP_GAMES.tsv) or ``"psx"`` (PSX_GAMES.tsv).
|
``content_type`` is ``"psp"`` (PSP_GAMES.tsv), ``"psx"`` (PSX_GAMES.tsv),
|
||||||
Downloading decrypts the ``.pkg`` with the bundled pure-Python decryptor
|
``"theme"`` (PSP_THEMES.tsv) or ``"dlc"`` (PSP_DLCS.tsv). Downloading
|
||||||
and writes a ``<TITLE_ID>/EBOOT.PBP`` game folder into ``dest_dir``.
|
decrypts the ``.pkg`` with the bundled pure-Python decryptor and writes a
|
||||||
|
``<TITLE_ID>/`` game folder into ``dest_dir``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
label = "NoPayStation"
|
label = "NoPayStation"
|
||||||
|
|
||||||
def __init__(self, content_type, tsv_url="", pkg2zip_path=""):
|
def __init__(self, content_type, tsv_url="", pkg2zip_path="", force=False):
|
||||||
from . import nopaystation
|
from . import nopaystation
|
||||||
self.content_type = content_type
|
self.content_type = content_type
|
||||||
self.tsv_url = tsv_url or nopaystation.tsv_url(content_type)
|
self.tsv_url = tsv_url or nopaystation.tsv_url(content_type)
|
||||||
self.pkg2zip = pkg2zip_path or ""
|
self.pkg2zip = pkg2zip_path or ""
|
||||||
|
self.force = force
|
||||||
|
|
||||||
def scan(self, root=None):
|
def scan(self, root=None):
|
||||||
from . import nopaystation
|
from . import nopaystation
|
||||||
return nopaystation.fetch_items(self.content_type, self.tsv_url)
|
return nopaystation.fetch_items(self.content_type, self.tsv_url, force=self.force)
|
||||||
|
|
||||||
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
|
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
|
||||||
from . import nopaystation
|
from . import nopaystation
|
||||||
dest_dir = Path(dest_dir)
|
dest_dir = Path(dest_dir)
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# The bundled Python decryptor handles PSP/PSX directly; pkg2zip is
|
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly;
|
||||||
# only an optional fallback if a binary happens to be configured.
|
# pkg2zip is only an optional fallback if a binary happens to be
|
||||||
|
# configured (and only supports games/PSX).
|
||||||
pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip)
|
pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip)
|
||||||
title_id = item.get("title_id") or item.get("content_id") or ""
|
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"
|
pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg"
|
||||||
@@ -501,8 +504,21 @@ class NoPayStationSource(Source):
|
|||||||
if progress_cb is not None:
|
if progress_cb is not None:
|
||||||
progress_cb("Downloading", 0.0, "")
|
progress_cb("Downloading", 0.0, "")
|
||||||
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
||||||
|
kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None
|
||||||
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir, pkg2zip,
|
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir, pkg2zip,
|
||||||
progress_cb, cancel_event)
|
kind_hint, progress_cb, cancel_event)
|
||||||
|
if self.content_type in ("theme", "dlc"):
|
||||||
|
_write_sidecar(folder, item)
|
||||||
return folder
|
return folder
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sidecar(folder, item):
|
||||||
|
"""Save the store display name beside a downloaded theme/DLC (for the library)."""
|
||||||
|
try:
|
||||||
|
meta = {"name": item.get("name") or "", "title_id": item.get("title_id") or "",
|
||||||
|
"content_id": item.get("content_id") or ""}
|
||||||
|
(Path(folder) / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user