Compare commits

...
57 Commits
Author SHA1 Message Date
quoije b378214e9b Run transfer queue jobs concurrently (max 3) so downloads don't block each other
build / Linux x86_64 (push) Successful in 3m15s
build / Windows x64 (Wine) (push) Successful in 3m41s
2026-09-14 12:50:05 -04:00
quoije 0110b50a07 Download cover bundle through the transfer queue (fix QProgressDialog NameError) 2026-09-14 12:47:13 -04:00
quoije 76eb6a6ef4 Smooth store source transitions: cancel stale batches and batch grid updates 2026-09-14 00:49:50 -04:00
quoije 58d348859a Fix store FD exhaustion: run cover/date loaders one at a time 2026-09-14 00:38:37 -04:00
quoije 3359ecf131 Fix store lag: decode cached covers off the GUI thread
build / Linux x86_64 (push) Successful in 2m48s
build / Windows x64 (Wine) (push) Successful in 3m17s
2026-09-14 00:24:48 -04:00
quoije 38a3859a21 Unbundle covers: fetch psppt-covers.zip from repo on demand with user prompt
build / Linux x86_64 (push) Successful in 2m28s
build / Windows x64 (Wine) (push) Successful in 3m8s
2026-09-14 00:03:09 -04:00
quoije 96e876b8ec Harden shutdown: no segfault closing mid-load (thread cleanup + graceful Ctrl+C) 2026-09-13 23:49:17 -04:00
quoije 733e3c9089 Fix UI freeze on first cover load (decode+scale off the GUI thread)
build / Linux x86_64 (push) Failing after 8m22s
build / Windows x64 (Wine) (push) Canceled after 17m46s
2026-09-13 23:20:08 -04:00
quoije dc7d6c9720 Rebrand to psppt (psp-preserve -> psppt) 2026-09-13 23:08:22 -04:00
quoije 5c5bb6e5d6 fix line break 2026-09-13 22:57:08 -04:00
quoije 35db22e3c5 Add grrr meme to README (resized to 156x148) 2026-09-13 22:55:46 -04:00
quoije b0cdac5483 Put contents_directory on EXE (COLLECT reads it from the exe) 2026-09-13 22:33:04 -04:00
quoije f367bbe298 Clean /usr/bin install: contents dir 'lib' + install.sh
build / Linux x86_64 (push) Successful in 13m11s
build / Windows x64 (Wine) (push) Successful in 20m38s
2026-09-13 21:47:32 -04:00
quoije c61d8e9d3f Revert contents_directory (collides with exe name)
build / Linux x86_64 (push) Failing after 3m8s
build / Windows x64 (Wine) (push) Canceled after 0s
2026-09-13 21:43:28 -04:00
quoije 78f6d44c73 Fix frozen-build SSL certs, taskbar icon, and rename _internal to psp-preserve
build / Linux x86_64 (push) Failing after 7m17s
build / Windows x64 (Wine) (push) Canceled after 0s
- config: point SSL at certifi bundle when frozen (fixes store/NPS/TMDB SSL errors)
- gui: set app name + window icon for the taskbar
- spec: contents_directory=psp-preserve; bundle a .desktop file for Wayland launchers
2026-09-13 21:19:56 -04:00
quoije a7f90cd5a7 Runner image: use WineHQ wine-staging + embeddable Python (Wine 8.0 too old for Py3.12) 2026-09-13 21:13:30 -04:00
quoije c9575817fe Runner image: use full Python installer (bundles pip + OpenSSL) 2026-09-13 21:11:45 -04:00
quoije b576ff5747 Settings: add Storage section showing state dir, DB, cover cache, and user data paths 2026-09-13 21:07:23 -04:00
quoije 6510229b07 Add cover-grid sorting to Library and Store
Library: Title / Release date / Size / Region / Type / Serial
Store: Newest first / Title / Region / Size / In library first
2026-09-13 21:03:49 -04:00
quoije e61d91418e Runner image: add wine32 (winetricks regedit needs 32-bit Wine) 2026-09-13 20:52:42 -04:00
quoije a1c461f6aa Runner image: use embeddable Python (avoid 32-bit installer bootstrap) 2026-09-13 20:50:57 -04:00
quoije 985c8df2cc Runner image: add xauth (xvfb-run requirement) 2026-09-13 20:38:48 -04:00
quoije 6eab2eca9c Use upload-artifact v3 (Gitea lacks v4 artifact protocol)
build / Linux x86_64 (push) Successful in 13m34s
build / Windows x64 (Wine) (push) Canceled after 0s
2026-09-13 20:28:57 -04:00
quoije eac29c428d Add Wine-based runner image for Windows builds
- runner/Dockerfile: Debian + Wine + Windows CPython 3.12 + PyInstaller + act_runner
- runner/entrypoint.sh: self-register with the windows:host label
- workflow windows job now builds via winpython under Wine
2026-09-13 20:18:06 -04:00
quoije ce00e3f518 Add PyInstaller spec and Gitea Actions CI for Linux/Windows binaries
build / Linux x86_64 (push) Failing after 13m46s
build / Windows x64 (push) Canceled after 0s
- run.py: top-level entry point for PyInstaller (relative imports need it)
- psp-preserve.spec: onedir build bundling DATs, loader, cover bundles, icons
- requirements-build.txt: pinned build deps (PySide6 6.8.3 for Windows ICU fix)
- .gitea/workflows/build.yml: builds linux + windows artifacts on tags/manual
- gitignore build/ dist/
2026-09-13 20:09:55 -04:00
quoije c19717402c Polish settings, add matching defaults, drop pkg2zip, externalize infra config
- Rework Settings tab into grouped sections
- Add preferred-region matching and full-hash-by-default setting
- Replace combo-box square arrow with a proper chevron
- Fix UMD Refresh button (clicked bool was clearing the view)
- Remove pkg2zip.exe fallback (pure-Python decryptor is the primary path)
- Move LAN IP/share paths to .env; use example defaults in config
2026-09-13 20:03:44 -04:00
quoije 1ec2e81e15 Scan tab: remove duplicate Scan button, add redo-match-choice context menu 2026-09-13 19:31:28 -04:00
quoije bd9cf47ca9 Run saves scan in background so app startup doesn't freeze 2026-09-09 23:15:18 -04:00
quoije 6e06d1389c Background scrape/eject/refresh, PS1 PIC0 art polish, top-right logo, and updated screenshot 2026-09-09 21:25:34 -04:00
quoije 8c5f9b6ea0 Dark theme, scan auto-ingest, packaging prep, and README refresh 2026-09-09 18:17:23 -04:00
quoije 74164b33ec Update screenshot (5-column grid) 2026-09-09 12:28:18 -04:00
quoije 75db7a3faa Fill cover grid width in library and store (no dead gap) 2026-09-09 12:27:46 -04:00
quoije ed3cc9bc8a Update README screenshot 2026-09-08 23:59:34 -04:00
quoije e01013ce6a Fix DLC switch freeze: skip bulk cover scraping for themes/DLC 2026-09-08 23:55:51 -04:00
quoije 318a920873 Fix store scan signals running on worker thread (bound methods, not lambdas) 2026-09-08 23:27:29 -04:00
quoije ec9f5e6d8e Store: fix NPS refresh, scan race, thread cleanup, and matching perf 2026-09-08 23:18:23 -04:00
quoije be54ff00aa Re-enable cover/metadata scraping for themes and DLC 2026-09-08 23:04:24 -04:00
quoije 115cdd84b0 Store: show cached theme previews in grid and details panel 2026-09-08 22:56:58 -04:00
quoije 62ce4b1913 PSP Sync: show friendly theme/DLC names instead of raw filenames 2026-09-08 22:51:36 -04:00
quoije 46c675ce79 PSP Sync: list themes and DLC in the device inventory 2026-09-08 22:45:36 -04:00
quoije 42fac24cf1 Skip ISO.BIN.EDAT wrapper so DLC shows as a single library item 2026-09-08 22:15:36 -04:00
quoije 7cc5debfa6 Skip NoPayStation items with MISSING (delisted) pkg URLs 2026-09-08 22:00:56 -04:00
quoije c03f4619e6 Show theme/DLC names and embedded theme previews in the library 2026-09-08 17:32:11 -04:00
quoije 1be33be4ab Fix NPS store content-type dropdown fetching wrong list 2026-09-07 23:58:54 -04:00
quoije 2fed95da05 Skip cover/metadata scraping for NoPayStation themes and DLC 2026-09-07 23:53:55 -04:00
quoije c94bcdc993 Add NoPayStation themes, DLC and minis support 2026-09-07 23:34:56 -04:00
quoije 398c9f5dad Add PSP game Category Lite setting for NoPayStation installs 2026-09-07 23:00:04 -04:00
quoije 2ba24028a0 Port PSN pkg decryption to pure Python (drop Wine requirement) 2026-09-07 22:45:00 -04:00
quoije d458a9859c nps: bundle pkg2zip binary and run it via Wine
pkg2zip's prebuilt releases are Windows-only, so ship pkg2zip.exe in
data/bin/ (MIT, mmozeiko) and invoke it through Wine on Linux. find_pkg2zip
prefers the bundled binary, then a configured path, then a native pkg2zip on
PATH. Adds data/bin/ATTRIBUTION.md and README credit.
2026-09-07 22:14:14 -04:00
quoije 6837481f2b gui: narrow saves checkbox column + app window icon
- Saves checkbox column fixed at 28px.
- Add a PlayStation-style app icon (img/app-icon.png) and set it as the
  window icon on launch.
2026-09-07 22:04:27 -04:00
quoije 0ef7d05462 saves: bigger icons, per-row checkboxes, delete + export
- Save icon bumped to 56x40.
- Added a checkbox column for multi-select and a 'Delete selected' button.
- Right-click context menu on a row offers Export (copy the save folder to a
  chosen dir) and Delete (remove from all locations, with confirmation).
2026-09-07 21:48:41 -04:00
quoije de931f422a saves: show the save's ICON0.PNG in the table
The Save column now displays a small (40x32, letterboxed) icon from the
save folder's ICON0.PNG when present, using the first location (PSP,
PPSSPP, cloud) that has one.
2026-09-07 21:45:10 -04:00
quoije 4e0218a562 gui: stop scrape-all dialog from reappearing after cancel
Cancelling the store scrape now closes the dialog and drops its reference
immediately, so queued progress signals can't re-show it (QProgressDialog
re-shows on setValue while a value is pending). _scraping stays true until
the worker finishes to prevent a second concurrent scrape.
2026-09-07 21:41:53 -04:00
quoije 3032f04b72 modify .gitignore 2026-09-07 21:36:40 -04:00
quoije d02152ca06 fix NoPayStation PSP parsing + detail-panel UnboundLocalError
- PSP_GAMES.tsv and PSX_GAMES.tsv use different headers (PSP adds Type/RAP
  columns, PSX adds Original Name). Parse the header row and map columns by
  name instead of hardcoding positions — PSP names were showing as 'Minis'/
  'PC Engine' (the Type column) with broken download URLs.
- GameDetailsPanel.set_item referenced is_ps1 before it was assigned, raising
  UnboundLocalError and leaving the detail panel stale.
2026-09-07 21:32:50 -04:00
quoije efc8522117 nps: cache TSV lists in data/nps/ instead of the state dir
TSVs live with the other data assets; users can also drop PSP_GAMES.tsv /
PSX_GAMES.tsv there manually to browse offline.
2026-09-07 21:22:43 -04:00
quoije 53f671d09a store: add NoPayStation source (PSP + PS1 Classics .pkg)
New 'NoPayStation' option in the Store tab lists PSP_GAMES.tsv and
PSX_GAMES.tsv, downloads each item's encrypted .pkg and decrypts/extracts
it with pkg2zip into a configurable output dir (<dir>/PSP/<TITLE_ID>/ and
<dir>/PSX/<TITLE_ID>/). Downloaded PSP games and PS1 Classics appear in the
Library (games/eboot and ps1/eboot respectively) and copy to the PSP as
PSP/GAME folders.

- app/nopaystation.py: TSV fetch/parse (cached) + pkg2zip discovery/extract.
- app/transfers.py: NoPayStationSource (scan + download).
- Settings: NPS output dir + pkg2zip path (QSettings nps_dir/pkg2zip_path).
- StorePage: NPS source + content-type combo (PSP Games / PS1 Classics),
  per-item cover system, TSV release dates, nps-download job kind.
- Library: _nps_library_items ingest, on-PSP detection, folder copy/delete,
  PPSSPP play disabled for encrypted PSP EBOOTs.
- README: document the source, pkg2zip + npdrm_free requirements.
2026-09-07 21:18:55 -04:00
43 changed files with 3765 additions and 454 deletions
+12
View File
@@ -0,0 +1,12 @@
# Copy this file to `.env` and fill in your values. `.env` is git-ignored.
# Static LAN IP for the API container on Unraid's macvlan bridge (br0).
PSP_LAN_IP=192.168.10.100
# Host paths mounted into the container (Unraid shares).
PSP_STORE_HOST=/mnt/user/downloads/Sony - PlayStation Portable
PSP_UMD_GAMES_HOST=/mnt/user/psp/umd_games
PSP_UMD_VIDEOS_HOST=/mnt/user/psp/umd_videos
# Secret token the desktop app sends as `Authorization: Bearer <token>`.
PSP_API_TOKEN=
+60
View File
@@ -0,0 +1,60 @@
name: build
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
linux:
name: Linux x86_64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-build.txt
- name: Build
run: pyinstaller --clean --noconfirm psppt.spec
- name: Package tarball
run: |
cp packaging/install.sh dist/psppt/
cp packaging/psppt.desktop dist/psppt/
cp img/app-icon.png dist/psppt/
tar -czf psppt-linux-x86_64.tar.gz -C dist psppt
- uses: actions/upload-artifact@v3
with:
name: psppt-linux-x86_64
path: psppt-linux-x86_64.tar.gz
windows:
name: Windows x64 (Wine)
runs-on: windows
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
run: |
winpython -m pip install --upgrade pip
winpython -m pip install -r requirements-build.txt
- name: Build
run: winpython -m PyInstaller --clean --noconfirm psppt.spec
- name: Package zip
run: |
cd dist && zip -rq ../psppt-windows-x64.zip psppt
- uses: actions/upload-artifact@v3
with:
name: psppt-windows-x64
path: psppt-windows-x64.zip
+14
View File
@@ -14,3 +14,17 @@ data/*.pbp
# User-supplied PS1 Classics art templates (extracted from copyrighted PSN
# EBOOTs) — kept local only; the app falls back to drawn art when absent.
data/ps1_templates/
# nps tsv files
data/nps/*.tsv
# Local environment overrides (LAN paths, secrets). See .env.example
.env
# PyInstaller output
build/
dist/
# Offline cover bundles (large; fetched from the project release on first run)
data/covers/*.zip
/psppt-covers.zip
+1 -1
View File
@@ -3,5 +3,5 @@
# Port-forward 80 and 443 on your router to this host.
hostname.com {
reverse_proxy psp-preserve:8000
reverse_proxy psppt:8000
}
+188 -41
View File
@@ -1,26 +1,31 @@
# PSP Preservation Tool
# psppt
the name is terrible, i know.
![grrr](img/grrr.png)
![AI Generated](https://raw.githubusercontent.com/dotnetdave/ai-usage-badges/refs/heads/main/badges/png/1x/ai-generated.png)
Native Linux GUI (PySide6) for preserving PSP UMD **games** and **videos** as
`.iso` files. It scans your discs, verifies them against the full Redump catalog
(Redump DATs), reports what's missing, and retags/renames discs to a canonical
scheme. It also fetches cover art and metadata, syncs discs and save-data with a
real PSP, can pull missing discs from your own Redump 7z archive collection, and
converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
A cross-platform (Linux + Windows) desktop GUI — PySide6 — for preserving PSP
UMD **games** and **videos** as `.iso` files. It scans your discs, verifies them
against the Redump/No-Intro catalogs, reports what's missing, and retags/renames
discs to a canonical scheme. It fetches cover art and metadata, syncs discs and
save-data with a real PSP, can pull missing discs from a Redump 7z archive
collection, downloads PSN content from NoPayStation, and converts PlayStation 1
ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
![PSP Preservation Tool](img/qt-gui.png)
* Use default app theme for Qt.
![psppt](img/qt-gui.png)
* Dark theme with a blue accent — the Library tab in cover view. *
## Features
- **Library** — scan folders of `.iso` discs, verify against the DAT catalog,
browse with cover art, search/filter, see per-game details (developer,
publisher, genre, release date, synopsis, trailer), and launch discs in PPSSPP.
- **Store** — browse and download discs from three sources: a local 7z folder,
your own HTTP store server, or an archive.org item (see
[Store](#store--archiveorg-and-local-folder)).
- **Store** — browse and download discs from four sources: a local 7z folder,
your own HTTP store server, an archive.org item, or NoPayStation (PSP/PS1
PSN `.pkg` content) (see [Store](#store--archiveorg-and-local-folder)).
- **Queue** — background transfers with progress, cancel, per-row delete, etc.
- **PSP Sync** — detect a connected PSP (USB) or a mounted UMD, inventory what's
installed (games / UMD videos / homebrew / PKG), copy discs to the PSP, delete
@@ -35,11 +40,15 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
## Requirements
- Linux + Python 3
- `PySide6` — the only Python dependency (`pip install -r requirements.txt`)
- `7z` (p7zip) — used to peek/extract disc images from store archives
- Python 3.10+ on Linux or Windows
- `PySide6`, `curl_cffi`, `pycryptodome` (`pip install -r requirements.txt`)
- `7z` (p7zip / 7-Zip) — used to peek/extract disc images from store archives
- PPSSPP (optional) — to launch discs from the details panel
NoPayStation `.pkg` files are decrypted by the bundled **pure-Python
decryptor** (`app/pkg.py`) — no Wine or `pkg2zip` required
(see [NoPayStation](#4-nopaystation)).
## Install
```bash
@@ -54,6 +63,77 @@ pip install -r requirements.txt
python3 -m app.main
```
## Building binaries
The GUI is packaged with PyInstaller into a self-contained folder. Build
requirements are pinned in `requirements-build.txt` (PySide6 6.8.3 on purpose —
see the note inside).
```bash
python3 -m venv .venv-build
. .venv-build/bin/activate
pip install -r requirements-build.txt
pyinstaller --clean --noconfirm psppt.spec
# output: dist/psppt/ → run dist/psppt/psppt
```
Notes:
- The build bundles the DATs, PS1 loader, cover templates, and index catalogs
(~80 MB total). The large offline cover zips (~850 MB) are **not** bundled —
the app fetches them on first run (see
[Offline bundles](#offline-bundles--auto-download)).
- `7z` (p7zip-full on Linux, 7-Zip on Windows) is a **runtime** dependency for
reading store archives — it is not bundled.
- User data (credentials, manual covers, PS1 templates, NPS cache, library DB,
cover cache) lives in the OS state dir, never next to the binary.
### CI (Gitea Actions)
`.gitea/workflows/build.yml` builds Linux and Windows artifacts on version-tag
pushes (`v*`) or on manual trigger (**Actions → build → Run workflow**).
Artifacts appear on the Actions run page and can be attached to a release.
Each job needs a [Gitea Actions runner](https://docs.gitea.com/usage/actions/act-runner)
registered with the matching label:
```bash
# Linux runner (Docker mode, run on your server)
./act_runner register \
--instance https://git.ceaf.dev \
--token <registration-token> \
--name linux-builder \
--labels ubuntu-latest:docker://node:20-bookworm
# Windows runner (host mode, run on a Windows machine)
act_runner.exe register \
--instance https://git.ceaf.dev \
--token <registration-token> \
--name win-builder \
--labels windows:host
```
The registration token is generated in Gitea under
**Site Administration → Actions → Runners → Create new runner**.
#### Windows builds without a Windows box (Wine runner)
The `windows` job targets a runner labelled `windows` that builds under Wine on
Linux. Build it from `runner/`:
```bash
docker build -f runner/Dockerfile -t psp-wine-runner .
docker run -d --name psp-wine-runner --restart unless-stopped \
-e GITEA_INSTANCE_URL=https://git.ceaf.dev \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<registration-token> \
-v /mnt/user/appdata/psp-wine-runner:/data \
psp-wine-runner
```
The image ships Wine + Windows CPython 3.12 + PyInstaller, and registers itself
with the `windows:host` label. (If you have a real Windows machine instead,
register a native `act_runner.exe` with the same `windows` label.)
## First-time setup
1. Put your DAT files in `data/dats/`:
@@ -62,14 +142,15 @@ python3 -m app.main
Redump lacks — keeps serial-based matching working).
- videos: No-Intro `Sony - PlayStation Portable (UMD Video)` (Redump has
no UMD Video DAT).
2. Ingest them into the local SQLite library:
2. Start the app — any DATs missing from the library are ingested automatically
at startup (no manual step). You can also re-ingest on demand:
```bash
python3 -m app.ingest
```
3. Open the GUI and point **Settings → General → Games/Videos directory** at
your disc folders (or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`).
3. Point **Settings → General → Games/Videos directory** at your disc folders
(or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`).
4. (Optional) PS1 conversion — copy any PSN PS1 classic `EBOOT.PBP` to
`data/base.PBP` (it supplies Sony's encrypted loader). Then set the **PS1
directory**, **PS1 output directory** and **PS1 base.pbp** in
@@ -91,9 +172,10 @@ Discs are matched by hash first (most precise), then serial, then filename.
- `data/credentials.json` — cover/metadata API keys (IGDB, TheMovieDB,
ScreenScraper, Google Custom Search). Environment variables take precedence.
- `data/manual_covers/games|videos/` — your own cover images.
- `data/covers/` — checked-in assets: the UMD video/game cover templates,
`libretro-index.json` + `openvgdb-index.json` catalogs, `libretro-covers.zip` +
`openvgdb-covers.zip` offline cover bundles, and the legacy cover cache.
- `data/covers/` — checked-in assets: the UMD video/game cover templates and
the `libretro-index.json` + `openvgdb-index.json` catalogs. The large
`libretro-covers.zip` + `openvgdb-covers.zip` bundles are git-ignored and
distributed as `psppt-covers.zip` (see above).
- `data/base.PBP` — a PSN PS1 classic EBOOT (any game) supplying `STARTDAT`
+ a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted).
- `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS
@@ -101,24 +183,29 @@ Discs are matched by hash first (most precise), then serial, then filename.
- `data/ps1_templates/` — optional PS1 Classics art templates
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
to drawn art when they're absent.
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev;
a packaged build stores them under the state dir instead).
- State dir (`~/.local/state/psppt/` on Linux, `%LOCALAPPDATA%\psppt`
on Windows, or `$PSP_STATE_DIR`):
- `library.sqlite` — the catalog + library database.
- `covers/` — downloaded cover cache.
- `data/` — user-writable data in packaged builds (credentials, manual
covers, PS1 templates, NPS TSV cache).
SQLite is kept on a local disk because SQLite locking is unreliable over
network filesystems; the DATs, manual covers and credentials stay in `data/`
(which may be a network share).
network filesystems. Read-only resources (DATs, the POPS loader, icons) are
bundled with the app; user-writable data lives in the state dir.
## Configuration
Everything is driven by environment variables, with GUI settings (stored in
QSettings under `PSPPreserve`) taking precedence where noted.
QSettings under `psppt`) taking precedence where noted.
| Variable | Default | Purpose |
|---|---|---|
| `PSP_DATA_DIR` | `./data` | data root (DATs, credentials, manual covers) |
| `PSP_DATS_DIR` | `$PSP_DATA_DIR/dats` | DAT folder |
| `PSP_STATE_DIR` | `~/.local/state/psp-preserve` | local state root |
| `PSP_STATE_DIR` | `~/.local/state/psppt` | local state root |
| `PSP_DB_PATH` | `$PSP_STATE_DIR/library.sqlite` | SQLite library |
| `PSP_COVERS_DIR` | `$PSP_STATE_DIR/covers` | cover cache |
| `PSP_GAMES_ROOTS` | `/data/umd_games` | comma-separated game disc roots |
@@ -126,16 +213,20 @@ QSettings under `PSPPreserve`) taking precedence where noted.
| `PSP_PS1_ROOTS` | `/data/ps1` | comma-separated PS1 disc roots |
| `PSP_PS1_OUTPUT_DIR` | `/data/ps1_converted` | converted EBOOT.PBP output folder |
| `PSP_SCAN_WORKERS` | `1` | parallel hashing workers |
| `PSP_STORE_DIR` | `/media/downloads/Sony - PlayStation Portable` | local 7z store folder |
| `PSP_STORE_DIR` | `/data/store` | local 7z store folder |
| `PSP_STORE_SERVER` | `https://psp.example.com` | store server base URL |
| `PSP_STORE_TOKEN` | *(empty)* | store server bearer token |
| `PSP_ARCHIVE_ITEM` | `psp-redump-roms321.com` | archive.org item identifier |
| `PSP_ARCHIVE_LOGGED_IN_SIG` | *(empty)* | archive.org `logged-in-sig` cookie |
| `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie |
| `PSP_NPS_DIR` | `<state>/nps` | NoPayStation output folder |
| `PSP_NPS_TSV_BASE` | `https://nopaystation.com/tsv/` | NoPayStation TSV base URL |
| `PSP_API_TOKEN` | *(empty)* | server API bearer token (unset = open) |
| `PSP_CREDENTIALS` | `$PSP_DATA_DIR/credentials.json` | credentials file |
| `PSP_USER_DATA_DIR` | `<state>/data` (packaged) / `./data` (dev) | user-writable data (credentials, manual covers, PS1 templates, NPS cache) |
| `PSP_CREDENTIALS` | `$PSP_USER_DATA_DIR/credentials.json` | credentials file |
| `PSP_LIBRETRO_COVERS_URL` | *(empty)* | direct URL for `libretro-covers.zip` (else derived from store server) |
| `PSP_OVGDB_COVERS_URL` | *(empty)* | direct URL for `openvgdb-covers.zip` (else derived from store server) |
| `PSP_COVERS_BUNDLE_URL` | Gitea `covers` release asset | combined `psppt-covers.zip` download URL |
Cover/metadata keys can also be set as env vars: `IGDB_CLIENT_ID`,
`IGDB_CLIENT_SECRET`, `TMDB_API_KEY`, `GOOGLE_API_KEY`, `GOOGLE_CX`,
@@ -168,12 +259,23 @@ Manual covers in `data/manual_covers/videos/` always win.
### Offline bundles & auto-download
The two bundles (`libretro-covers.zip`, `openvgdb-covers.zip`) are shipped in
`data/covers/`, so a fresh install works with **no internet and no API keys**.
If either is missing at startup, the app downloads it from the store server's
`/api/covers/<name>.zip` endpoint (see [Server setup](#server-setup)).
The two bundles (`libretro-covers.zip`, `openvgdb-covers.zip`) are packaged
into one `psppt-covers.zip` (~850 MB) and attached to the repo's `covers`
release — **not** bundled into the app binary.
Rebuild them yourself:
On first launch, if the bundle isn't installed, the app asks whether to
download it (with a progress bar). Covers keep working online via libretro /
OpenVGDB / ScreenScraper / IGDB without it — the bundle just makes them instant
and offline. "Don't ask again" is remembered, and you can re-download from
**Settings → Cover sources → Download offline cover bundle…**.
Build the bundle yourself:
```bash
python3 scripts/make-covers-bundle.py # -> psppt-covers.zip
```
Rebuild the inner zips:
```bash
python3 -m app.libretro # libretro-thumbnails -> libretro-covers.zip
@@ -222,7 +324,7 @@ Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` /
`PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc
PBP); disc data is DEFLATE-compressed (level 1 by default, `--complevel 0-9`).
## Store — archive.org and local folder
## Store — four sources
The **Store** tab downloads missing discs from a Redump 7z archive collection
(`Name (Region) (tags).7z`, containing the `.iso`/`.cso` inside). It has three
@@ -258,6 +360,48 @@ If the item's files are marked **private** (login required), set the
from a logged-in archive.org browser session). Without them, private files are
skipped — and you'll get a clear error if *everything* in the item is private.
### 4. NoPayStation
The **NoPayStation** source lists PSN content from
[NoPayStation](https://nopaystation.com/), selectable from the content-type
dropdown 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:
1. `.pkg` files are decrypted by the built-in **pure-Python decryptor**
(`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no
`pkg2zip` needed.
2. Set **NoPayStation output dir** in **Settings → Store** (default
`$PSP_STATE_DIR/nps`).
3. Downloads land in the NPS folder and appear in the **Library**:
- PSP game → `PSP/<TITLE_ID>/EBOOT.PBP` (`psp_kind="eboot"`).
- PS1 Classic → `PSX/<TITLE_ID>/EBOOT.PBP` (+ `DOCUMENT.DAT`, `KEYS.BIN`).
- Theme → `THEME/<TITLE_ID>/<content_id>.ptf` (the packaged PTF is
EDAT-unwrapped to a raw theme file).
- 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).
> **DRM note:** NPS content is PSN-DRM'd. To *run* it on the PSP you need the
> [`npdrm_free`](https://github.com/qwikrazor87/npdrm_free) plugin (ARK and most
> CFW already ship it). The desktop app only downloads and decrypts; it doesn't
> strip DRM.
>
> **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
The catalog + store API is a single-file, stdlib-only HTTP server
@@ -298,13 +442,14 @@ Token protection: set `PSP_API_TOKEN`; the client sends
macvlan bridge so it gets its own LAN IP (e.g. `192.168.1.100`, plain HTTP on 8000):
```bash
# in /path/to/psp-preserve/ — adjust the volume paths for your shares
printf 'PSP_API_TOKEN=<long-random-secret>\n' > .env
# in /path/to/psppt/
cp .env.example .env # then edit: LAN IP, share paths, API token
docker compose up -d --build
```
The compose file mounts your Unraid shares into `/data/store`, `/data/umd_games`
and `/data/umd_videos` (the store is mounted read-only).
The compose file reads the LAN IP and Unraid share paths from `.env` and
mounts them into `/data/store`, `/data/umd_games` and `/data/umd_videos`
(the store is mounted read-only).
### TLS (optional)
@@ -324,12 +469,14 @@ 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:
- **Games / UMD Videos / Homebrew-PSN / PKG** — tables of what's installed, with
delete-from-PSP (checkboxes and right-click).
- **Games / UMD Videos / Homebrew-PSN / Themes / DLC / PKG** — tables of what's
installed, with delete-from-PSP (checkboxes and right-click).
- **UMD** — reads `PARAM.SFO` from a mounted UMD, shows the title/serial, and
can dump the disc image into your library.
- **Sync from Library** — copy discs from your library onto the PSP, optionally
into a Category Lite category (set in **Settings → PSP → ISO category**).
NoPayStation PSP games install under `/PSP/GAME`, optionally nested in the
**PSP game category**; PS1 Classics use the **PS1 category**.
## Saves sync
+1 -1
View File
@@ -1 +1 @@
"""PSP Preservation Tool application package."""
"""psppt application package."""
+2 -2
View File
@@ -251,7 +251,7 @@ def _library(conn, system=None, refresh=False):
class Handler(BaseHTTPRequestHandler):
server_version = "PSPPreserve/0.1"
server_version = "psppt/0.1"
def do_GET(self):
parsed = urlparse(self.path)
@@ -262,7 +262,7 @@ class Handler(BaseHTTPRequestHandler):
try:
if path == "/":
_json(self, {
"name": "PSP Preservation Tool API",
"name": "psppt API",
"endpoints": [
"/api/summary",
"/api/releases?system=games|videos&q=&limit=&offset=",
+56 -6
View File
@@ -1,8 +1,33 @@
"""Central configuration, driven by environment variables."""
import os
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# PyInstaller sets ``sys.frozen`` and unpacks bundled files to ``sys._MEIPASS``.
# Read-only resources (DATs, loader, icons) then live in the bundle; otherwise
# they live in the project checkout.
IS_FROZEN = getattr(sys, "frozen", False)
_BUNDLE_DIR = getattr(sys, "_MEIPASS", None)
PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent
# Frozen bundles don't ship the system CA store, so Python's urllib would fail
# TLS verification against the store server / NoPayStation / TMDB / archive.org.
# Point OpenSSL at the certifi bundle (a curl_cffi dependency) when frozen only;
# source runs keep using the OS CA store.
if IS_FROZEN:
try:
import certifi
_CA_BUNDLE = certifi.where()
os.environ.setdefault("SSL_CERT_FILE", _CA_BUNDLE)
os.environ.setdefault("REQUESTS_CA_BUNDLE", _CA_BUNDLE)
except Exception: # noqa: BLE001
pass
def resource_path(*parts):
"""Absolute path to a read-only bundled resource (works when frozen)."""
return PROJECT_ROOT.joinpath(*parts)
DATA_DIR = Path(os.environ.get("PSP_DATA_DIR", str(PROJECT_ROOT / "data")))
DATS_DIR = Path(os.environ.get("PSP_DATS_DIR", str(DATA_DIR / "dats")))
@@ -12,25 +37,38 @@ def _default_state_dir():
"""Local state directory (fast, reliable local disk).
SQLite locking is unreliable over network filesystems (SMB/FUSE), so the
library DB and the cover cache live on local storage while DATs, manual
covers and credentials stay in ``DATA_DIR`` (which may be a network share).
library DB and the cover cache live on local storage while read-only DATs
stay bundled with the app.
"""
if os.name == "nt":
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or str(Path.home())
return Path(base) / "psppt"
xdg = os.environ.get("XDG_STATE_HOME")
base = Path(xdg) if xdg else Path.home() / ".local" / "state"
return base / "psp-preserve"
return base / "psppt"
STATE_DIR = Path(os.environ.get("PSP_STATE_DIR", str(_default_state_dir())))
DB_PATH = Path(os.environ.get("PSP_DB_PATH", str(STATE_DIR / "library.sqlite")))
COVERS_DIR = Path(os.environ.get("PSP_COVERS_DIR", str(STATE_DIR / "covers")))
# User-writable data (credentials, manual covers, PS1 templates, NPS TSV cache).
# In dev this is the checkout's ``data/``; when frozen the bundle is read-only,
# so it points at local state that survives app upgrades.
USER_DATA_DIR = Path(os.environ.get(
"PSP_USER_DATA_DIR", str(DATA_DIR if not IS_FROZEN else STATE_DIR / "data")))
LEGACY_DB_PATH = DATA_DIR / "library.sqlite"
LEGACY_COVERS_DIR = DATA_DIR / "covers"
def user_data_path(*parts):
"""Absolute path to a user-writable data file/folder (works when frozen)."""
return USER_DATA_DIR.joinpath(*parts)
# Redump 7z store (browsable in the Store tab). Env override; GUI setting wins.
STORE_ROOT = Path(os.environ.get("PSP_STORE_DIR", "/media/downloads/Sony - PlayStation Portable"))
STORE_ROOT = Path(os.environ.get("PSP_STORE_DIR", "/data/store"))
# Remote store server (HTTPS). Used by the Store tab as the primary source.
STORE_SERVER_URL = os.environ.get("PSP_STORE_SERVER", "https://psp.ceaf.dev")
STORE_SERVER_URL = os.environ.get("PSP_STORE_SERVER", "https://psp.example.com")
STORE_TOKEN = os.environ.get("PSP_STORE_TOKEN", "")
# Optional direct URLs for the offline cover bundle zips. When unset, the GUI
@@ -38,6 +76,13 @@ STORE_TOKEN = os.environ.get("PSP_STORE_TOKEN", "")
LIBRETRO_COVERS_URL = os.environ.get("PSP_LIBRETRO_COVERS_URL", "")
OVGDB_COVERS_URL = os.environ.get("PSP_OVGDB_COVERS_URL", "")
# Combined offline cover bundle (both inner zips) fetched on first run when the
# user opts in. Defaults to the project's Gitea release asset.
COVERS_BUNDLE_URL = os.environ.get(
"PSP_COVERS_BUNDLE_URL",
"https://git.ceaf.dev/quoije/psp-preservation-tool/releases/download/covers/psppt-covers.zip",
)
# archive.org item hosting the same Redump 7z set (identifier or full URL).
ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com")
@@ -46,6 +91,11 @@ ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com")
ARCHIVE_LOGGED_IN_SIG = os.environ.get("PSP_ARCHIVE_LOGGED_IN_SIG", "")
ARCHIVE_LOGGED_IN_USER = os.environ.get("PSP_ARCHIVE_LOGGED_IN_USER", "")
# NoPayStation: where extracted .pkg content lands, and the TSV source base.
# PSP games go to <NPS_OUTPUT_DIR>/PSP/<TITLE_ID>/; PS1 Classics to .../PSX/.
NPS_OUTPUT_DIR = Path(os.environ.get("PSP_NPS_DIR", str(STATE_DIR / "nps")))
NPS_TSV_BASE = os.environ.get("PSP_NPS_TSV_BASE", "https://nopaystation.com/tsv/")
# Scan roots (used by the scanner phase). Comma-separated in env.
GAMES_ROOTS = [Path(p) for p in os.environ.get("PSP_GAMES_ROOTS", "/data/umd_games").split(",") if p.strip()]
VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()]
+113 -55
View File
@@ -52,9 +52,21 @@ _IGDB_IMAGE = "https://images.igdb.com/igdb/image/upload/"
_TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
_GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1"
_MANUAL_DIR = config.DATA_DIR / "manual_covers"
_OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip"
_LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip"
_MANUAL_DIR = config.USER_DATA_DIR / "manual_covers"
# Offline cover bundles live in the writable cover dir (downloaded on demand),
# not the read-only app bundle. A source checkout keeps them in data/covers/.
_OVGDB_ZIP = config.COVERS_DIR / "openvgdb-covers.zip"
_LIBRETRO_ZIP = config.COVERS_DIR / "libretro-covers.zip"
_BUNDLE_ZIP = config.COVERS_DIR / "psppt-covers.zip"
def _bundle_zip_path(name):
"""Resolve a cover bundle zip: writable dir first, legacy checkout second."""
for base in (config.COVERS_DIR, config.LEGACY_COVERS_DIR):
p = base / name
if p.exists():
return p
return config.COVERS_DIR / name
_WIKI_SEARCH = "https://en.wikipedia.org/w/api.php"
_WIKI_SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
@@ -62,7 +74,7 @@ _WIKI_SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
_JPEG_MAGIC = b"\xff\xd8\xff"
_MAX_HOPS = 3 # follow libretro symlink text bodies at most this many times
_UA = {"User-Agent": "psp-preserve/0.1 (https://github.com; local tool)"}
_UA = {"User-Agent": "psppt/0.1 (https://github.com; local tool)"}
_igdb_token_state = {"token": "", "expires": 0.0} # IGDB OAuth2 token cache
_igdb_lock = threading.Lock()
_igdb_last = 0.0 # monotonic clock of the last IGDB API call
@@ -158,48 +170,74 @@ def clear_cover_cache():
pass
def _download_file(url, dest, timeout=600):
"""Stream `url` to `dest` (atomic); returns True on success."""
def _download_file(url, dest, timeout=600, progress_cb=None, cancel_event=None):
"""Stream `url` to `dest` (atomic); returns True on success.
``progress_cb(done, total)`` is called with bytes so far (total is 0 when
the server doesn't report a content length). ``cancel_event`` aborts the
download when set.
"""
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
return False
total = int(resp.headers.get("Content-Length") or 0)
tmp = dest.with_name(dest.name + ".tmp")
done = 0
with open(tmp, "wb") as f:
while True:
if cancel_event is not None and cancel_event.is_set():
raise InterruptedError("download cancelled")
chunk = resp.read(1 << 20)
if not chunk:
break
f.write(chunk)
done += len(chunk)
if progress_cb is not None:
progress_cb(done, total)
os.replace(tmp, dest)
return True
def ensure_cover_bundles(server_url=""):
"""Download missing offline cover bundle zips from the store server.
def covers_bundle_present():
"""True when the offline cover bundle is installed (both inner zips)."""
return (_bundle_zip_path("libretro-covers.zip").exists()
and _bundle_zip_path("openvgdb-covers.zip").exists())
``server_url`` is the base URL (e.g. ``http://192.168.10.100:8000``); the
zips are fetched from ``<base>/api/covers/<name>.zip`` unless a direct URL
is set in config. Returns the names of bundles downloaded.
def download_covers_bundle(url=None, progress_cb=None, cancel_event=None):
"""Download and extract the combined cover bundle into the cover dir.
``url`` defaults to ``config.COVERS_BUNDLE_URL``. Raises on failure; the
caller decides whether to surface the error.
"""
base = (server_url or config.STORE_SERVER_URL or "").rstrip("/")
downloaded = []
for name, path, direct in (
("libretro-covers.zip", _LIBRETRO_ZIP, config.LIBRETRO_COVERS_URL),
("openvgdb-covers.zip", _OVGDB_ZIP, config.OVGDB_COVERS_URL),
):
if path.exists():
continue
url = direct or (f"{base}/api/covers/{name}" if base else "")
if not url:
continue
try:
if _download_file(url, path):
downloaded.append(name)
log.info("downloaded offline cover bundle %s", name)
except Exception as exc: # noqa: BLE001
log.warning("could not download cover bundle %s: %s", name, exc)
return downloaded
url = (url or config.COVERS_BUNDLE_URL or "").strip()
if not url:
raise ValueError("no cover bundle URL configured")
config.COVERS_DIR.mkdir(parents=True, exist_ok=True)
_download_file(url, _BUNDLE_ZIP, timeout=3600,
progress_cb=progress_cb, cancel_event=cancel_event)
inner = ("libretro-covers.zip", "openvgdb-covers.zip")
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
missing = [n for n in inner if n not in z.namelist()]
if missing:
raise ValueError(f"cover bundle is missing: {', '.join(missing)}")
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
for name in inner:
out = config.COVERS_DIR / name
tmp = out.with_name(out.name + ".tmp")
with z.open(name) as src, open(tmp, "wb") as dst:
while True:
chunk = src.read(1 << 20)
if not chunk:
break
dst.write(chunk)
os.replace(tmp, out)
# Invalidate cached namelists so lookups pick up the freshly extracted zips.
global _libretro_names, _ovgdb_names
_libretro_names = None
_ovgdb_names = None
return config.COVERS_DIR
def cover_url(name, kind="Named_Boxarts", system="games"):
@@ -411,6 +449,9 @@ def _fetch_libretro(name, timeout, system="games", indexed_only=False):
data = fetch_libretro_local(name)
if data is not None:
return data
# Theme/DLC share the PSP thumbnails repo, so reuse the games index instead
# of downloading a duplicate copy of the same tree under a new system key.
idx_system = system if system in _PLATFORMS else "games"
if not indexed_only:
# 1. Cheap: exact + progressively stripped names (no index needed).
for kind in _LIBRETRO_KINDS:
@@ -421,7 +462,7 @@ def _fetch_libretro(name, timeout, system="games", indexed_only=False):
# 2. Fuzzy/exact via the precomputed index (covers exact names too, so the
# bulk loader skips the wasteful exact pass entirely).
for kind in _LIBRETRO_KINDS:
best = _index_best(name, kind, system)
best = _index_best(name, kind, idx_system)
if best:
data = _fetch_png(best, kind, timeout, system)
if data is not None:
@@ -479,9 +520,10 @@ def _ovgdb_name_list():
global _ovgdb_names
if _ovgdb_names is None:
_ovgdb_names = set()
if _OVGDB_ZIP.exists():
path = _bundle_zip_path("openvgdb-covers.zip")
if path.exists():
try:
with zipfile.ZipFile(_OVGDB_ZIP) as z:
with zipfile.ZipFile(path) as z:
_ovgdb_names = {n[:-4] for n in z.namelist() if n.endswith(".jpg")}
except Exception: # noqa: BLE001
pass
@@ -532,7 +574,7 @@ def fetch_openvgdb_local(name):
if entry is None:
return None
try:
with zipfile.ZipFile(_OVGDB_ZIP) as z:
with zipfile.ZipFile(_bundle_zip_path("openvgdb-covers.zip")) as z:
data = z.read(entry)
return data if _is_image(data) else None
except Exception: # noqa: BLE001
@@ -556,7 +598,7 @@ def fetch_libretro_local(name):
if entry is None:
return None
try:
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
with zipfile.ZipFile(_bundle_zip_path("libretro-covers.zip")) as z:
data = z.read(entry)
return data if _is_image(data) else None
except Exception: # noqa: BLE001
@@ -567,9 +609,10 @@ def _libretro_name_list():
global _libretro_names
if _libretro_names is None:
_libretro_names = set()
if _LIBRETRO_ZIP.exists():
path = _bundle_zip_path("libretro-covers.zip")
if path.exists():
try:
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
with zipfile.ZipFile(path) as z:
_libretro_names = {n[:-4] for n in z.namelist() if n.endswith(".png")}
except Exception: # noqa: BLE001
pass
@@ -607,7 +650,7 @@ def _fetch_screenscraper(name, timeout, system="games"):
params = {
"devid": devid,
"devpassword": devpassword,
"softname": creds.get("screenscraper_softname") or "psp-preserve",
"softname": creds.get("screenscraper_softname") or "psppt",
"output": "json",
"systemeid": _platform(system)["ss_id"],
"romtype": "rom",
@@ -808,7 +851,7 @@ def compose_game_cover(key_art_bytes):
return bytes(ba)
_PS1_TEMPLATE_DIR = config.DATA_DIR / "ps1_templates"
_PS1_TEMPLATE_DIR = config.USER_DATA_DIR / "ps1_templates"
def _ps1_template_bytes(name):
@@ -934,25 +977,28 @@ def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor,
QRectF, Qt, _save):
W, H = 310, 180
img = QImage(W, H, QImage.Format_ARGB32)
img.fill(QColor(0, 0, 0, 0))
# Dark gradient backdrop.
# Dark gradient backdrop clipped to a rounded card (transparent corners).
grad = QLinearGradient(0, 0, 0, H)
grad.setColorAt(0.0, QColor(38, 38, 40))
grad.setColorAt(1.0, QColor(12, 12, 14))
card = QPainterPath()
card.addRoundedRect(QRectF(0, 0, W, H), 12, 12)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing, True)
p.setRenderHint(QPainter.TextAntialiasing, True)
p.fillRect(0, 0, W, H, grad)
p.fillPath(card, grad)
# Rounded border.
# Rounded card border.
p.setPen(QPen(QColor(110, 110, 115), 1))
p.setBrush(Qt.NoBrush)
p.drawRoundedRect(QRectF(4, 4, W - 8, H - 8), 9, 9)
p.drawPath(card)
# PlayStation button glyphs, top-right.
_draw_ps_glyphs(p, W - 34, 12, 22, QColor(200, 200, 205), QRectF, QPointF,
QPolygonF, QPen, Qt)
_draw_ps_glyphs(p, W - 34, 12, 22, QRectF, QPointF, QPolygonF, QPen, Qt,
QColor)
# Title (bold, upper-left). Shrink the font until the whole title fits, so
# long names are shown in full instead of being cut with an ellipsis.
@@ -974,6 +1020,10 @@ def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor,
p.drawText(QRectF(18, 16, title_width, 28), Qt.AlignLeft | Qt.AlignVCenter,
title_text)
# Thin divider under the title (like the original PSN info board).
p.setPen(QPen(QColor(255, 255, 255, 55), 1))
p.drawLine(QPointF(18, 46), QPointF(W - 18, 46))
# Description (wrapped to at most 3 lines, last line elided).
if description:
f_desc = QFont("DejaVu Sans")
@@ -982,7 +1032,7 @@ def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor,
p.setPen(QColor(215, 215, 215))
fm = QFontMetrics(f_desc)
lines = _wrap_elided(fm, description.strip(), W - 36, 3, Qt)
y = 46
y = 52
for line in lines:
p.drawText(QRectF(18, y, W - 36, 16), Qt.AlignLeft | Qt.AlignVCenter, line)
y += 16
@@ -1024,23 +1074,31 @@ def _wrap_elided(fm, text, width, max_lines, Qt):
return lines[:max_lines]
def _draw_ps_glyphs(p, x, y, size, color, QRectF, QPointF, QPolygonF, QPen, Qt):
"""Draw the four PlayStation face-button glyphs in a 2x2 grid."""
def _draw_ps_glyphs(p, x, y, size, QRectF, QPointF, QPolygonF, QPen, Qt, QColor):
"""Draw the four PlayStation face-button glyphs in a 2x2 grid, using the
official button colours (green triangle, red circle, blue cross, pink square)."""
cell = size / 2.0
pen = QPen(color, max(1.0, size / 14.0))
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
# Triangle (top-left).
def _pen(color):
pen = QPen(color, max(1.0, size / 14.0))
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
return pen
# Triangle (top-left, green).
p.setPen(_pen(QColor("#3F9B0F")))
tri = QPolygonF([QPointF(x, y + cell), QPointF(x + cell, y), QPointF(x + cell, y + cell)])
p.drawPolygon(tri)
# Circle (top-right).
# Circle (top-right, red).
p.setPen(_pen(QColor("#DE1C18")))
p.drawEllipse(QRectF(x + cell, y, cell, cell))
# Cross (bottom-left).
# Cross (bottom-left, blue).
p.setPen(_pen(QColor("#287FC1")))
p.drawLine(QPointF(x + 1, y + cell + 1), QPointF(x + cell - 1, y + size - 1))
p.drawLine(QPointF(x + cell - 1, y + cell + 1), QPointF(x + 1, y + size - 1))
# Square (bottom-right).
# Square (bottom-right, pink).
p.setPen(_pen(QColor("#C94E9B")))
p.drawRect(QRectF(x + cell, y + cell, cell, cell))
+1323 -277
View File
File diff suppressed because it is too large Load Diff
+29 -15
View File
@@ -23,6 +23,9 @@ _miss_db = {}
_miss_loaded = False
_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():
global _miss_loaded
@@ -122,6 +125,7 @@ def save(release_id, system, meta, manual=False, title=None):
)
conn.commit()
conn.close()
_invalidate_dates_cache()
return True
except sqlite3.Error:
return False
@@ -140,6 +144,7 @@ def reset(release_id, system):
)
conn.commit()
conn.close()
_invalidate_dates_cache()
except sqlite3.Error:
pass
@@ -161,23 +166,32 @@ def cached_release_dates():
"""Return {(release_id, system): release_date} from cached metadata only.
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 = {}
try:
conn = get_connection(config.DB_PATH)
rows = conn.execute("SELECT release_id, system, meta_json FROM item_info").fetchall()
conn.close()
except sqlite3.Error:
return {}
for r in rows:
global _dates_cache
if _dates_cache is None:
out = {}
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"]
return out
conn = get_connection(config.DB_PATH)
rows = conn.execute("SELECT release_id, system, meta_json FROM item_info").fetchall()
conn.close()
except sqlite3.Error:
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):
+36
View File
@@ -88,6 +88,42 @@ def ingest_dat(conn, path):
return dat_name, system, release_count, rom_count
def ensure_catalog(dats_dir=None, conn=None):
"""Ingest any DAT files missing from the DB (idempotent, cheap when current).
Called once at startup so a fresh/reset database still has the catalog and
scanning produces matches instead of everything coming back ``unknown``.
Returns the number of DATs ingested.
"""
dats_dir = Path(dats_dir) if dats_dir else config.DATS_DIR
if not dats_dir.is_dir():
return 0
files = sorted(p for p in dats_dir.glob("*.dat") if p.is_file())
if not files:
return 0
own = conn is None
if own:
conn = get_connection(config.DB_PATH)
try:
known = {r["source_path"] for r in conn.execute("SELECT source_path FROM dats")}
count = 0
for path in files:
if str(path) in known:
continue
try:
ingest_dat(conn, path)
except Exception as exc: # noqa: BLE001 - a bad DAT shouldn't block startup
print(f"warning: could not ingest {path.name}: {exc}", file=sys.stderr)
continue
count += 1
conn.commit()
return count
finally:
if own:
conn.close()
def main(argv=None):
parser = argparse.ArgumentParser(description="Ingest clrmamepro DAT files into SQLite")
parser.add_argument("--dats", help="directory containing .dat files (default: data/dats)")
+4 -2
View File
@@ -48,8 +48,10 @@ def launch_ppsspp(iso, exe=""):
else:
return False, "PPSSPP not found. Install it or set its path in Settings → Saves."
try:
subprocess.Popen(cmd, start_new_session=True, env=_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
kwargs = {"env": _env(), "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
if os.name != "nt":
kwargs["start_new_session"] = True # POSIX-only: detach from our process group
subprocess.Popen(cmd, **kwargs)
return True, ""
except OSError as exc:
return False, str(exc)
+1 -1
View File
@@ -1,4 +1,4 @@
"""PSP Preservation Tool entry point (native GUI).
"""psppt entry point (native GUI).
Run with::
+35 -1
View File
@@ -111,6 +111,36 @@ def normalize_name(name):
return _normalize(name)
_EUROPEAN_REGIONS = {
"europe", "germany", "france", "spain", "italy", "russia", "netherlands",
"portugal", "poland", "united kingdom", "greece", "norway", "sweden",
"denmark", "finland", "austria", "belgium", "switzerland",
}
def region_matches(region, prefer):
"""True if ``region`` satisfies the user's preferred-region hint."""
if not prefer or not region:
return False
tokens = {t.strip().lower() for t in str(region).split(",") if t.strip()}
if not tokens:
return False
prefer = prefer.strip().lower()
if prefer in tokens:
return True
if prefer == "europe":
return bool(tokens & _EUROPEAN_REGIONS)
return False
def apply_region_preference(candidates, prefer):
"""Narrow ``candidates`` to the preferred region (fall back to all)."""
if not prefer:
return candidates
filtered = [c for c in candidates if region_matches(c.get("region"), prefer)]
return filtered or candidates
def serial_candidates(conn, record):
"""Return all catalog releases matching the disc's serial (size-narrowed)."""
serial = record.get("serial")
@@ -131,7 +161,7 @@ def serial_candidates(conn, record):
return _narrow(conn, [dict(r) for r in rows], record.get("size"))
def match(conn, record):
def match(conn, record, prefer_region=None):
size = record.get("size")
# 1. Hash is the most precise key; serials are shared across revisions.
@@ -171,6 +201,10 @@ def match(conn, record):
for cand in candidates:
if cand["id"] == saved["release_id"]:
return "choice", cand
if prefer_region:
filtered = [c for c in candidates if region_matches(c.get("region"), prefer_region)]
if len(filtered) == 1:
return "region", filtered[0]
return "unknown", None
# 4. Fuzzy fallback (filename + PARAM.SFO title), size-disambiguated.
+210
View File
@@ -0,0 +1,210 @@
"""NoPayStation (.pkg) browsing and extraction.
NoPayStation publishes per-platform TSV lists (``https://nopaystation.com/tsv/``)
with one row per downloadable PSN item. Each row links to an encrypted PSN
``.pkg`` (a ``\\x7FPKG`` container, not a ZIP) that is decrypted by the bundled
pure-Python decryptor (``app/pkg.py``) into a ``PSP/GAME/<TITLE_ID>/``-style
folder — no Wine or ``pkg2zip`` required.
"""
from __future__ import annotations
import logging
import shutil
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import List
from . import config
log = logging.getLogger("nopaystation")
_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
# differ per platform (PSP has "Type"/"RAP" columns, PSX has "Original Name").
# Map header names to canonical keys instead of relying on fixed positions.
_HEADER_ALIASES = {
"title id": "title_id",
"region": "region",
"name": "name",
"pkg direct link": "pkg_url",
"content id": "content_id",
"last modification date": "date",
"file size": "size",
"sha256": "sha256",
"zrif": "zrif",
"rap": "rap",
"download .rap file": "rap_url",
"type": "content_kind",
"original name": "original_name",
}
def tsv_url(content_type: str, base: str = "") -> str:
"""TSV URL for a content type (``psp`` / ``psx`` / ``theme`` / ``dlc``)."""
base = (base or config.NPS_TSV_BASE).rstrip("/")
return f"{base}/{_TSV_NAMES.get(content_type, _TSV_NAMES['psp'])}"
def _tsv_dir() -> Path:
d = config.USER_DATA_DIR / "nps"
d.mkdir(parents=True, exist_ok=True)
return d
def _parse_date(value: str) -> str:
value = (value or "").strip()
if not value:
return ""
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(value, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return value[:10]
def parse_tsv(text: str, content_type: str) -> List[dict]:
"""Parse a NoPayStation TSV into store item dicts.
The header row drives column mapping (PSP and PSX TSVs differ). Each item
carries the fields the Store page expects (``name``, ``size``,
``release_date``, ``mtime_ns``) plus ``title_id``, ``pkg_url``, ``sha256``,
``content_type`` and the cover ``system`` ("games" for PSP, "ps1" for PSX).
"""
system = {"psp": "games", "psx": "ps1", "theme": "theme", "dlc": "dlc"}.get(content_type, "games")
items: List[dict] = []
header = None
for line in text.splitlines():
line = line.rstrip("\r\n")
if not line.strip():
continue
cols = line.split("\t")
if header is None:
header = [c.strip().lower() for c in cols]
continue
row = {}
for i, key in enumerate(header):
canon = _HEADER_ALIASES.get(key)
if canon and i < len(cols):
row[canon] = cols[i].strip()
name = row.get("name") or ""
pkg_url = row.get("pkg_url") or ""
# 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
title_id = row.get("title_id") or ""
try:
size = int(float(row.get("size") or 0))
except (TypeError, ValueError):
size = 0
date = _parse_date(row.get("date") or "")
try:
mtime_ns = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1_000_000_000) if date else 0
except ValueError:
mtime_ns = 0
items.append({
"name": name,
"title_id": title_id,
"region": row.get("region") or "",
"pkg_url": pkg_url,
"zrif": row.get("zrif") or "",
"content_id": row.get("content_id") or "",
"size": size,
"sha256": (row.get("sha256") or "").strip().lower(),
"release_date": date,
"mtime_ns": mtime_ns,
"content_type": content_type,
"system": system,
"content_kind": row.get("content_kind") or "",
})
return items
def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool = False) -> List[dict]:
"""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)
name = _TSV_NAMES.get(content_type, "PSP_GAMES.tsv")
cache = _tsv_dir() / name
def read_cache():
try:
return parse_tsv(cache.read_text(encoding="utf-8", errors="replace"), content_type)
except OSError:
return None
if not force and cache.is_file():
items = read_cache()
if items is not None:
return items
try:
req = urllib.request.Request(url, headers={"User-Agent": "psppt/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 _safe_folder(value: str) -> str:
import re
cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
return cleaned or "PKG"
def _ensure_keys_bin(folder, kind):
"""PS1 Classics need KEYS.BIN next to EBOOT.PBP to boot on CFW (npdrm)."""
if kind != "psx":
return
src = config.DATA_DIR / "loader" / "KEYS.BIN"
dest = Path(folder) / "KEYS.BIN"
if src.is_file() and not dest.exists():
try:
shutil.copy2(src, dest)
except OSError:
pass
def extract_pkg(pkg_path, title_id: str, dest_dir,
kind_hint: str = None, progress_cb=None, cancel_event=None) -> Path:
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
Uses the bundled pure-Python decryptor (no Wine or pkg2zip). Returns the
destination game folder path.
"""
from . import pkg as pkgmod
from .store import TransferCancelled
pkg_path = Path(pkg_path)
dest = Path(dest_dir) / _safe_folder(title_id)
if dest.is_dir() and any(dest.iterdir()):
return dest
try:
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
progress_cb, cancel_event)
_ensure_keys_bin(folder, _kind)
return folder
except TransferCancelled:
raise
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"could not decrypt {pkg_path.name}: {exc}") from exc
+1 -1
View File
@@ -155,7 +155,7 @@ def scrape(limit=None, workers=8, rate=3.0):
log.info("downloading OpenVGDB dump (%s)…", OVGDB_ZIP_URL)
tmpdir = Path(tempfile.mkdtemp(prefix="openvgdb-"))
dump_zip = tmpdir / "openvgdb.zip"
req = urllib.request.Request(OVGDB_ZIP_URL, headers={"User-Agent": "psp-preserve/0.1"})
req = urllib.request.Request(OVGDB_ZIP_URL, headers={"User-Agent": "psppt/0.1"})
with urllib.request.urlopen(req, timeout=60) as r, open(dump_zip, "wb") as f:
f.write(r.read())
+2 -2
View File
@@ -2,9 +2,9 @@
from . import iso_reader, matcher, namer
def identify(conn, record):
def identify(conn, record, prefer_region=None):
"""Return ``{method, release, serial, canonical}`` for a scanned disc record."""
method, release = matcher.match(conn, record)
method, release = matcher.match(conn, record, prefer_region=prefer_region)
return identify_with_release(record, release, method)
+378
View File
@@ -0,0 +1,378 @@
"""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:
parse the ``\\x7FPKG`` header, derive the PSP/PS3 AES key, AES-CTR-decrypt the
file entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder.
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 pathlib import Path
PKG_MAGIC = 0x7F504B47
# Static PSN keys (key type 1 = PSP).
PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
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:
from Crypto.Cipher import AES as _AES # pycryptodome (fast)
_HAVE_CRYPTO = True
except ImportError: # noqa: BLE001
_HAVE_CRYPTO = False
# --------------------------------------------------------------------------- #
# Pure-Python AES-128 (fallback when pycryptodome is unavailable)
# --------------------------------------------------------------------------- #
_SBOX = bytes.fromhex(
"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
"09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
"d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
"cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
"e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
"ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
"e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16"
)
_RCON = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36)
def _key_schedule(key):
w = list(key)
for i in range(4, 44):
t = list(w[(i - 1) * 4:(i - 1) * 4 + 4])
if i % 4 == 0:
t = t[1:] + t[:1]
t = [_SBOX[b] for b in t]
t[0] ^= _RCON[i // 4 - 1]
w += [w[(i - 4) * 4 + j] ^ t[j] for j in range(4)]
return [bytes(w[i * 16:(i + 1) * 16]) for i in range(11)]
def _shift_rows(s):
return [s[0], s[5], s[10], s[15], s[4], s[9], s[14], s[3],
s[8], s[13], s[2], s[7], s[12], s[1], s[6], s[11]]
def _xtime(a):
a <<= 1
return (a ^ 0x1B) & 0xFF if a & 0x100 else a & 0xFF
def _mix_columns(s):
out = [0] * 16
for c in range(4):
a0, a1, a2, a3 = s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3]
out[4 * c] = _xtime(a0) ^ (_xtime(a1) ^ a1) ^ a2 ^ a3
out[4 * c + 1] = a0 ^ _xtime(a1) ^ (_xtime(a2) ^ a2) ^ a3
out[4 * c + 2] = a0 ^ a1 ^ _xtime(a2) ^ (_xtime(a3) ^ a3)
out[4 * c + 3] = (_xtime(a0) ^ a0) ^ a1 ^ a2 ^ _xtime(a3)
return out
def _aes_encrypt_block(rk, block):
s = [block[i] ^ rk[0][i] for i in range(16)]
for rnd in range(1, 10):
s = [_SBOX[b] for b in s]
s = _shift_rows(s)
s = _mix_columns(s)
s = [s[i] ^ rk[rnd][i] for i in range(16)]
s = [_SBOX[b] for b in s]
s = _shift_rows(s)
s = [s[i] ^ rk[10][i] for i in range(16)]
return bytes(s)
def _add128(counter, n):
c = bytearray(counter)
for i in range(15, -1, -1):
n += c[i]
c[i] = n & 0xFF
n >>= 8
return bytes(c)
def _ctr_decrypt_py(key, iv, block_index, data):
"""AES-CTR decrypt (pure-Python): counter = iv + block_index, +1 per block."""
rk = _key_schedule(key)
counter = _add128(iv, block_index)
out = bytearray()
for i in range(0, len(data), 16):
ks = _aes_encrypt_block(rk, counter)
out += bytes(b ^ k for b, k in zip(data[i:i + 16], ks))
counter = _add128(counter, 1)
return bytes(out)
def _ctr_decrypt(key, iv, block_index, data):
"""AES-CTR decrypt: 128-bit big-endian counter at ``iv + block_index``."""
if _HAVE_CRYPTO:
nonce = iv[:8]
init = int.from_bytes(iv[8:16], "big") + block_index
return _AES.new(key, _AES.MODE_CTR, nonce=nonce, initial_value=init).decrypt(data)
return _ctr_decrypt_py(key, iv, block_index, data)
def _make_ctr_decryptor(key, iv, block_index):
"""Return a streaming decryptor (callable: bytes -> bytes) for a data run."""
if _HAVE_CRYPTO:
nonce = iv[:8]
init = int.from_bytes(iv[8:16], "big") + block_index
cipher = _AES.new(key, _AES.MODE_CTR, nonce=nonce, initial_value=init)
return cipher.decrypt
rk = _key_schedule(key)
counter = _add128(iv, block_index)
state = {"rk": rk, "counter": counter}
def decrypt(chunk):
out = bytearray()
for i in range(0, len(chunk), 16):
ks = _aes_encrypt_block(state["rk"], state["counter"])
out += bytes(b ^ k for b, k in zip(chunk[i:i + 16], ks))
state["counter"] = _add128(state["counter"], 1)
return bytes(out)
return decrypt
# --------------------------------------------------------------------------- #
# 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
# --------------------------------------------------------------------------- #
def extract(pkg_path, dest_dir, kind_hint=None, progress_cb=None, cancel_event=None):
"""Decrypt a PSN pkg into ``dest_dir/<TITLE_ID>/``.
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
pkg_path = Path(pkg_path)
dest_dir = Path(dest_dir)
with open(pkg_path, "rb") as fh:
header = fh.read(0x100)
if len(header) < 0xE8 or int.from_bytes(header[0:4], "big") != PKG_MAGIC:
raise RuntimeError(f"not a PKG file: {pkg_path.name}")
meta_offset = int.from_bytes(header[8:12], "big")
meta_count = int.from_bytes(header[12:16], "big")
item_count = int.from_bytes(header[20:24], "big")
enc_offset = int.from_bytes(header[32:40], "big")
iv = header[0x70:0x80]
key_type = header[0xE7] & 7
title_id = header[0x37:0x40].decode("ascii", "replace").rstrip("\x00").strip() or "PKG"
content_type = 0
items_offset = 0
with open(pkg_path, "rb") as fh:
off = meta_offset
for _ in range(meta_count):
fh.seek(off)
m = fh.read(16)
if len(m) < 16:
break
mtype = int.from_bytes(m[0:4], "big")
msize = int.from_bytes(m[4:8], "big")
if mtype == 2:
content_type = int.from_bytes(m[8:12], "big")
elif mtype == 13:
items_offset = int.from_bytes(m[8:12], "big")
off += 8 + msize
if kind_hint in ("theme", "dlc"):
kind = kind_hint
elif content_type == 6:
kind = "psx"
elif content_type in (7, 0xE, 0xF, 0x10):
kind = "psp"
elif content_type == 9:
kind = "theme"
elif content_type == 0xB:
kind = "dlc"
else:
raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}")
# 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}")
# Decrypt the whole item table in one shot.
with open(pkg_path, "rb") as fh:
fh.seek(enc_offset + items_offset)
table = fh.read(item_count * 32)
table = _ctr_decrypt(main_key, iv, items_offset // 16, table)
entries = []
for i in range(item_count):
e = table[i * 32:(i + 1) * 32]
name_offset = int.from_bytes(e[0:4], "big")
name_size = int.from_bytes(e[4:8], "big")
data_offset = int.from_bytes(e[8:16], "big")
data_size = int.from_bytes(e[16:24], "big")
psp_type = e[24]
flags = e[27]
if flags in (4, 18) or data_size == 0:
continue
item_key = main_key if psp_type == 0x90 else ps3_key
entries.append((name_offset, name_size, data_offset, data_size, item_key))
folder = dest_dir / title_id
folder.mkdir(parents=True, exist_ok=True)
written = []
total = sum(ds for *_, ds, _ in entries) or 1
done = 0
with open(pkg_path, "rb") as fh:
for name_offset, name_size, data_offset, data_size, item_key in entries:
if cancel_event is not None and cancel_event.is_set():
raise TransferCancelled()
fh.seek(enc_offset + name_offset)
name = _ctr_decrypt(item_key, iv, name_offset // 16, fh.read(name_size))
name = name.split(b"\x00", 1)[0].decode("utf-8", "replace")
base = name.rsplit("/", 1)[-1]
if kind == "psx" and base not in ("EBOOT.PBP", "DOCUMENT.DAT"):
continue
if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"):
continue
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
if kind == "theme":
# Read the (small) entry fully, CTR-decrypt, then unwrap the EDAT.
fh.seek(enc_offset + data_offset)
raw = _ctr_decrypt(item_key, iv, data_offset // 16, fh.read(data_size))
dest = folder / base
dest.write_bytes(_unwrap_theme_ptf(raw))
done += data_size
else:
dest = folder / base
decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16)
fh.seek(enc_offset + data_offset)
with open(dest, "wb") as out:
remaining = data_size
while remaining > 0:
if cancel_event is not None and cancel_event.is_set():
raise TransferCancelled()
chunk = fh.read(min(1 << 20, remaining))
if not chunk:
break
out.write(decrypt(chunk))
remaining -= len(chunk)
done += len(chunk)
if progress_cb is not None:
progress_cb("Extracting", min(done / total, 1.0), "")
written.append(dest)
if kind in ("psp", "psx") and not (folder / "EBOOT.PBP").exists():
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
+4 -1
View File
@@ -40,6 +40,7 @@ import io
import os
import re
import struct
import sys
import zlib
from dataclasses import dataclass, field
from pathlib import Path
@@ -88,7 +89,9 @@ SYNC_PATTERN = b"\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00"
# (ARK/PRO/ME) loads this ELF directly as the DATA.PSP section, sidestepping
# the base PBP's encrypted ~PSP loader which frequently fails to link on
# 6.60/6.61 (error 80020148 "game could not be started").
_LOADER_DIR = Path(__file__).resolve().parent.parent / "data" / "loader"
_BUNDLE_DIR = getattr(sys, "_MEIPASS", None)
_APP_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent
_LOADER_DIR = _APP_ROOT / "data" / "loader"
BUNDLED_DATA_PSP = _LOADER_DIR / "DATA.PSP"
BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN"
+43 -1
View File
@@ -221,8 +221,48 @@ def _pkg_items(root):
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):
"""Return ``{games, videos, homebrew, pkg}`` inventories for a PSP root."""
"""Return ``{games, videos, homebrew, pkg, themes, dlc}`` for a PSP root."""
root = Path(root)
games, videos, homebrew_isos = _iso_items(root)
return {
@@ -230,6 +270,8 @@ def list_installed(root):
"videos": videos,
"homebrew": _homebrew_items(root) + homebrew_isos,
"pkg": _pkg_items(root),
"themes": _theme_items(root),
"dlc": _dlc_items(root),
}
+39 -36
View File
@@ -79,13 +79,13 @@ class TransferQueue(QObject):
job_finished = Signal(dict)
job_removed = Signal(int)
def __init__(self, parent=None):
def __init__(self, parent=None, max_concurrent=3):
super().__init__(parent)
self.max_concurrent = max(1, int(max_concurrent))
self.jobs = []
self._pending = []
self._current = None
self._thread = None
self._worker = None
# job_id -> (QThread, JobWorker) for jobs currently running in parallel.
self._running = {}
# Keep strong references to every worker/thread for their full lifetime.
# Dropping a Python ref to a QObject that was moved to another thread
# deletes it directly and crashes (bus error).
@@ -115,8 +115,10 @@ class TransferQueue(QObject):
self.job_updated.emit(job.to_dict())
self.job_finished.emit(job.to_dict())
return
if job.status == "running" and self._worker is not None:
self._worker.cancel()
if job.status == "running":
pair = self._running.get(job_id)
if pair is not None:
pair[1].cancel()
job.status = "cancelling"
self.job_updated.emit(job.to_dict())
@@ -140,8 +142,8 @@ class TransferQueue(QObject):
self.remove(jid)
def shutdown(self):
if self._worker is not None:
self._worker.cancel()
for _t, worker in self._running.values():
worker.cancel()
for t in self._threads:
try:
if t.isRunning():
@@ -157,30 +159,29 @@ class TransferQueue(QObject):
return None
def _start_next(self):
if self._current is not None or not self._pending:
return
job = self._pending.pop(0)
self._current = job
job.status = "running"
log.info("starting job #%d: %s (%s)", job.id, job.title, job.kind)
self.job_updated.emit(job.to_dict())
while len(self._running) < self.max_concurrent and self._pending:
job = self._pending.pop(0)
job.status = "running"
log.info("starting job #%d: %s (%s)", job.id, job.title, job.kind)
self.job_updated.emit(job.to_dict())
self._thread = QThread(self)
self._worker = JobWorker(job.source, job.dest_dir, job)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.progress.connect(self._on_progress)
self._worker.finished.connect(self._on_finished)
self._worker.skipped.connect(self._on_skipped)
self._worker.cancelled.connect(self._on_cancelled)
self._worker.failed.connect(self._on_failed)
for sig in (self._worker.finished, self._worker.skipped,
self._worker.cancelled, self._worker.failed):
sig.connect(self._thread.quit)
self._thread.finished.connect(self._on_thread_finished)
self._threads.append(self._thread)
self._workers.append(self._worker)
self._thread.start()
thread = QThread(self)
worker = JobWorker(job.source, job.dest_dir, job)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.progress.connect(self._on_progress)
worker.finished.connect(self._on_finished)
worker.skipped.connect(self._on_skipped)
worker.cancelled.connect(self._on_cancelled)
worker.failed.connect(self._on_failed)
for sig in (worker.finished, worker.skipped,
worker.cancelled, worker.failed):
sig.connect(thread.quit)
thread.finished.connect(self._on_thread_finished)
self._running[job.id] = (thread, worker)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_progress(self, job_id, stage, fraction, detail):
job = self._find(job_id)
@@ -218,10 +219,12 @@ class TransferQueue(QObject):
self.job_finished.emit(job.to_dict())
def _on_thread_finished(self):
# Queued to the main thread once the worker thread has fully stopped,
# so it is now safe to drop the "current" pointers and advance.
self._current = None
self._thread = None
self._worker = None
# Queued to the main thread once a worker thread has fully stopped, so
# it is now safe to drop its pointers and advance the queue.
thread = self.sender()
for jid, pair in list(self._running.items()):
if pair[0] is thread:
del self._running[jid]
break
self._start_next()
+1 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from . import config
CREDENTIALS_PATH = Path(
os.environ.get("PSP_CREDENTIALS", str(config.DATA_DIR / "credentials.json"))
os.environ.get("PSP_CREDENTIALS", str(config.USER_DATA_DIR / "credentials.json"))
)
FIELDS = [
+316
View File
@@ -0,0 +1,316 @@
"""Global dark theme for the GUI (Fusion style + palette + stylesheet).
Applied once at startup from ``gui.main``. The per-widget ``setStyleSheet``
colors sprinkled through ``gui.py`` (``#999`` secondary text, ``#e06c6c`` delete
buttons, ``#14151a`` preview panels) were written for a dark background, so a
single cohesive dark theme here makes the whole UI consistent.
"""
from PySide6.QtGui import QColor, QPalette
from . import config
# Design tokens
BG = "#1e1f24" # window background
SURFACE = "#26272e" # cards, inputs, table headers
RAISED = "#2d2e36" # buttons, combos, menus
BORDER = "#3a3b44"
BORDER_SOFT = "#33343d"
TEXT = "#e6e6ea"
TEXT_DIM = "#9a9ba5"
TEXT_FAINT = "#6f7079"
ACCENT = "#4a9eff"
ACCENT_HOVER = "#6bb0ff"
_STYLESHEET = f"""
QMainWindow {{
background: {BG};
}}
QWidget {{
color: {TEXT};
}}
/* --- Tabs --- */
QTabWidget::pane {{
border: 1px solid {BORDER};
border-radius: 6px;
top: -1px;
}}
QTabBar::tab {{
background: transparent;
color: {TEXT_DIM};
padding: 8px 18px;
border: none;
border-bottom: 2px solid transparent;
margin-right: 2px;
}}
QTabBar::tab:hover:!selected {{
color: {TEXT};
}}
QTabBar::tab:selected {{
color: {TEXT};
border-bottom: 2px solid {ACCENT};
}}
/* --- Buttons --- */
QPushButton {{
background: {RAISED};
border: 1px solid {BORDER};
border-radius: 6px;
padding: 6px 12px;
}}
QPushButton:hover {{
background: #343640;
border-color: {ACCENT_HOVER};
}}
QPushButton:pressed {{
background: {SURFACE};
}}
QPushButton:disabled {{
color: {TEXT_FAINT};
background: {SURFACE};
border-color: {BORDER_SOFT};
}}
QPushButton:checked {{
background: {ACCENT};
border-color: {ACCENT};
color: #101116;
}}
/* --- Inputs --- */
QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit {{
background: {SURFACE};
border: 1px solid {BORDER};
border-radius: 6px;
padding: 5px 8px;
selection-background-color: {ACCENT};
selection-color: #101116;
}}
QLineEdit:focus, QSpinBox:focus, QPlainTextEdit:focus, QTextEdit:focus {{
border-color: {ACCENT};
}}
QLineEdit:disabled, QSpinBox:disabled {{
color: {TEXT_FAINT};
}}
/* --- Combo boxes --- */
QComboBox {{
background: {RAISED};
border: 1px solid {BORDER};
border-radius: 6px;
padding: 4px 10px;
}}
QComboBox:hover {{
border-color: {ACCENT_HOVER};
}}
QComboBox::drop-down {{
border: none;
width: 22px;
}}
QComboBox::down-arrow {{
image: url(__CHEVRON__);
width: 12px;
height: 8px;
margin-right: 6px;
}}
QComboBox QAbstractItemView {{
background: {RAISED};
border: 1px solid {BORDER};
border-radius: 6px;
selection-background-color: rgba(74, 158, 255, 0.25);
selection-color: {TEXT};
outline: 0;
}}
/* --- Tables --- */
QTableWidget {{
background: transparent;
border: none;
gridline-color: {BORDER_SOFT};
}}
QHeaderView::section {{
background: {SURFACE};
color: {TEXT_DIM};
border: none;
border-bottom: 1px solid {BORDER};
padding: 6px 8px;
}}
QTableWidget::item {{
padding: 4px 6px;
}}
QTableWidget::item:selected {{
background: rgba(74, 158, 255, 0.22);
color: {TEXT};
}}
QTableCornerButton::section {{
background: {SURFACE};
border: none;
border-bottom: 1px solid {BORDER};
}}
/* --- Cover grid / lists --- */
QListWidget {{
background: transparent;
border: none;
}}
QListWidget::item {{
border-radius: 6px;
}}
QListWidget::item:hover:!selected {{
background: rgba(255, 255, 255, 0.04);
}}
QListWidget::item:selected {{
background: rgba(74, 158, 255, 0.22);
}}
/* --- Cards (details panels use QScrollArea) --- */
QScrollArea {{
background: {SURFACE};
border: none;
}}
/* --- Menus --- */
QMenu {{
background: {RAISED};
border: 1px solid {BORDER};
border-radius: 6px;
padding: 4px;
}}
QMenu::item {{
padding: 6px 26px 6px 12px;
border-radius: 4px;
}}
QMenu::item:selected {{
background: rgba(74, 158, 255, 0.25);
}}
QMenu::separator {{
height: 1px;
background: {BORDER};
margin: 4px 8px;
}}
/* --- Scrollbars --- */
QScrollBar:vertical {{
background: transparent;
width: 10px;
margin: 0;
}}
QScrollBar::handle:vertical {{
background: {BORDER};
border-radius: 5px;
min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{
background: {ACCENT};
}}
QScrollBar:horizontal {{
background: transparent;
height: 10px;
margin: 0;
}}
QScrollBar::handle:horizontal {{
background: {BORDER};
border-radius: 5px;
min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{
background: {ACCENT};
}}
QScrollBar::add-line, QScrollBar::sub-line {{
width: 0;
height: 0;
}}
QScrollBar::add-page, QScrollBar::sub-page {{
background: transparent;
}}
/* --- Splitter --- */
QSplitter::handle {{
background: transparent;
}}
QSplitter::handle:horizontal {{
width: 1px;
}}
QSplitter::handle:vertical {{
height: 1px;
}}
/* --- Misc --- */
QToolTip {{
background: {RAISED};
color: {TEXT};
border: 1px solid {BORDER};
padding: 4px 6px;
}}
QStatusBar {{
background: {SURFACE};
color: {TEXT_DIM};
border-top: 1px solid {BORDER};
}}
QProgressBar {{
background: {SURFACE};
border: 1px solid {BORDER};
border-radius: 6px;
text-align: center;
}}
QProgressBar::chunk {{
background: {ACCENT};
border-radius: 5px;
}}
QCheckBox {{
spacing: 8px;
}}
QCheckBox::indicator {{
width: 16px;
height: 16px;
border: 1px solid {BORDER};
border-radius: 4px;
background: {SURFACE};
}}
QCheckBox::indicator:checked {{
background: {ACCENT};
border-color: {ACCENT};
}}
QGroupBox {{
border: 1px solid {BORDER};
border-radius: 6px;
margin-top: 10px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 4px;
color: {TEXT_DIM};
}}
"""
def _dark_palette():
p = QPalette()
p.setColor(QPalette.Window, QColor(BG))
p.setColor(QPalette.WindowText, QColor(TEXT))
p.setColor(QPalette.Base, QColor(SURFACE))
p.setColor(QPalette.AlternateBase, QColor(RAISED))
p.setColor(QPalette.ToolTipBase, QColor(RAISED))
p.setColor(QPalette.ToolTipText, QColor(TEXT))
p.setColor(QPalette.Text, QColor(TEXT))
p.setColor(QPalette.Button, QColor(RAISED))
p.setColor(QPalette.ButtonText, QColor(TEXT))
p.setColor(QPalette.BrightText, QColor("#ff5c5c"))
p.setColor(QPalette.Link, QColor(ACCENT))
p.setColor(QPalette.Highlight, QColor(ACCENT))
p.setColor(QPalette.HighlightedText, QColor("#101116"))
p.setColor(QPalette.PlaceholderText, QColor(TEXT_FAINT))
faint = QColor(TEXT_FAINT)
for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
p.setColor(QPalette.Disabled, role, faint)
return p
def apply_theme(app):
"""Apply the dark Fusion theme to ``app`` (call once before showing UI)."""
app.setStyle("Fusion")
app.setPalette(_dark_palette())
chevron = str(config.resource_path("img", "chevron.png"))
app.setStyleSheet(_STYLESHEET.replace("__CHEVRON__", chevron))
+511
View File
@@ -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
+84 -2
View File
@@ -175,6 +175,33 @@ class Source:
raise NotImplementedError
class CoversBundleSource:
"""Download the combined offline cover bundle (runs as a queue job)."""
label = "psppt-covers.zip"
def __init__(self, url=""):
self.url = url
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
from . import covers
def on_progress(done, total):
if progress_cb is None:
return
frac = (done / total) if total else 0.0
detail = (f"{done / (1 << 20):.0f} / {total / (1 << 20):.0f} MB"
if total else "")
progress_cb("Downloading", frac, detail)
try:
result = covers.download_covers_bundle(self.url,
progress_cb=on_progress,
cancel_event=cancel_event)
except InterruptedError:
raise TransferCancelled() from None
return str(result)
class FileCopySource(Source):
"""Copy a single local file (library -> PSP or PSP -> library).
@@ -284,7 +311,7 @@ class RemoteStoreSource(Source):
self.token = token or ""
def _headers(self):
headers = {"User-Agent": "psp-preserve/0.1"}
headers = {"User-Agent": "psppt/0.1"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
@@ -347,7 +374,7 @@ class ArchiveOrgSource(Source):
return f"https://archive.org/download/{self.item}"
def _headers(self):
headers = {"User-Agent": "psp-preserve/0.1"}
headers = {"User-Agent": "psppt/0.1"}
cookie = "; ".join(
p for p in (
f"logged-in-sig={self.sig}" if self.sig else "",
@@ -463,3 +490,58 @@ class LocalShareSource(Source):
return dest
finally:
shutil.rmtree(tmp, ignore_errors=True)
class NoPayStationSource(Source):
"""Browse NoPayStation .pkg lists and install PSP/PSX PSN content.
``content_type`` is ``"psp"`` (PSP_GAMES.tsv), ``"psx"`` (PSX_GAMES.tsv),
``"theme"`` (PSP_THEMES.tsv) or ``"dlc"`` (PSP_DLCS.tsv). Downloading
decrypts the ``.pkg`` with the bundled pure-Python decryptor and writes a
``<TITLE_ID>/`` game folder into ``dest_dir``.
"""
label = "NoPayStation"
def __init__(self, content_type, tsv_url="", force=False):
from . import nopaystation
self.content_type = content_type
self.tsv_url = tsv_url or nopaystation.tsv_url(content_type)
self.force = force
def scan(self, root=None):
from . import nopaystation
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):
from . import nopaystation
dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly.
title_id = item.get("title_id") or item.get("content_id") or ""
pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg"
tmp = Path(tempfile.mkdtemp(prefix="nps-download-"))
pkg = tmp / f"{pkg_name}.pkg"
try:
if progress_cb is not None:
progress_cb("Downloading", 0.0, "")
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir,
kind_hint, progress_cb, cancel_event)
if self.content_type in ("theme", "dlc"):
_write_sidecar(folder, item)
return folder
finally:
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
Binary file not shown.
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
# NoPayStation TSV lists are cached here (downloaded on first store scan).
# You can also drop PSP_GAMES.tsv / PSX_GAMES.tsv here manually to work offline.
+8 -8
View File
@@ -1,12 +1,12 @@
services:
psp-preserve:
psppt:
build: .
container_name: psp-preserve
container_name: psppt
# Attach to Unraid's macvlan bridge so the API gets its own LAN IP.
# Plain HTTP for now; see Caddyfile to add Let's Encrypt TLS later.
networks:
br0:
ipv4_address: 192.168.10.100
ipv4_address: ${PSP_LAN_IP}
environment:
PSP_DATA_DIR: /data
PSP_DATS_DIR: /data/dats
@@ -16,14 +16,14 @@ services:
PSP_VIDEO_ROOTS: /data/umd_videos
PSP_STORE_DIR: /data/store
# Secret token the desktop app sends as `Authorization: Bearer <token>`.
# Set via a .env file next to this compose file.
# Set in the .env file next to this compose file.
PSP_API_TOKEN: ${PSP_API_TOKEN:-}
volumes:
- ./data:/data
# Adjust host paths to your Unraid share layout:
- /mnt/user/downloads/Sony - PlayStation Portable:/data/store:ro
- /mnt/user/psp/umd_games:/data/umd_games:ro
- /mnt/user/psp/umd_videos:/data/umd_videos:ro
# Host paths come from .env (see .env.example):
- ${PSP_STORE_HOST}:/data/store:ro
- ${PSP_UMD_GAMES_HOST}:/data/umd_games:ro
- ${PSP_UMD_VIDEOS_HOST}:/data/umd_videos:ro
restart: unless-stopped
networks:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

After

Width:  |  Height:  |  Size: 683 KiB

+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
# Install psppt to a system prefix.
#
# FHS-friendly layout:
# $PREFIX/bin/psppt symlink -> payload binary
# $PREFIX/lib/psppt/ app payload (binary + lib/)
# $PREFIX/share/applications/ desktop entry
# $PREFIX/share/icons/hicolor/256x256/apps/ app icon
#
# The PyInstaller onedir build keeps the executable and its `lib/` contents
# directory side by side, so both live under $PREFIX/lib/psppt/ and a
# symlink exposes the binary at $PREFIX/bin/psppt.
#
# Usage (run from inside the extracted tarball):
# sudo ./install.sh # install to /usr/local (default)
# sudo ./install.sh --prefix=/usr # install to /usr
# sudo ./install.sh --uninstall # remove from the default prefix
set -eu
PREFIX=/usr/local
MODE=install
for arg in "$@"; do
case "$arg" in
--prefix=*) PREFIX=${arg#--prefix=} ;;
--uninstall) MODE=uninstall ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
SRC_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
LIBDIR="$PREFIX/lib/psppt"
BINDIR="$PREFIX/bin"
APPDIR="$PREFIX/share/applications"
ICONDIR="$PREFIX/share/icons/hicolor/256x256/apps"
APP_BIN="$LIBDIR/psppt"
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "run as root (e.g. sudo)" >&2
exit 1
fi
}
uninstall() {
rm -f "$BINDIR/psppt"
rm -f "$APPDIR/psppt.desktop"
rm -f "$ICONDIR/psppt.png"
rm -rf "$LIBDIR"
echo "Uninstalled from $PREFIX"
}
if [ "$MODE" = uninstall ]; then
require_root
uninstall
exit 0
fi
require_root
# The tarball ships these next to this script.
for f in psppt lib psppt.desktop app-icon.png; do
if [ ! -e "$SRC_DIR/$f" ]; then
echo "missing $f (run install.sh from inside the extracted tarball)" >&2
exit 1
fi
done
mkdir -p "$LIBDIR" "$BINDIR" "$APPDIR" "$ICONDIR"
rm -rf "$LIBDIR"
mkdir -p "$LIBDIR"
cp -a "$SRC_DIR/psppt" "$LIBDIR/psppt"
cp -a "$SRC_DIR/lib" "$LIBDIR/lib"
install -m 0644 "$SRC_DIR/psppt.desktop" "$APPDIR/psppt.desktop"
install -m 0644 "$SRC_DIR/app-icon.png" "$ICONDIR/psppt.png"
ln -sfn "$APP_BIN" "$BINDIR/psppt"
echo "Installed to $PREFIX"
echo " binary: $BINDIR/psppt -> $APP_BIN"
echo " payload: $LIBDIR"
echo " desktop: $APPDIR/psppt.desktop"
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=psppt
Comment=Preserve and manage your PSP library
Exec=psppt
Icon=psppt
Terminal=false
Categories=Utility;AudioVideo;
StartupWMClass=psppt
+65
View File
@@ -0,0 +1,65 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec: build psppt (onedir).
Bundles the read-only resources (DATs, PS1 loader, cover templates, index
catalogs, icons) under ``lib/`` next to the executable. User-writable data
(credentials, manual covers, PS1 templates, NPS cache, library DB, cover
cache) lives in the OS state dir at runtime — see ``app/config.py``.
Usage: pyinstaller --clean --noconfirm psppt.spec
Output: dist/psppt/
"""
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules
datas = [
("data/dats", "data/dats"),
("data/loader", "data/loader"),
("data/supplement.json", "data"),
("data/covers/libretro-index.json", "data/covers"),
("data/covers/openvgdb-index.json", "data/covers"),
("data/covers/UMD_COVER_TEMPLATE.png", "data/covers"),
("data/covers/UMD_COVER_TEMPLATE_FEATURE.png", "data/covers"),
("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"),
("img/app-icon.png", "img"),
("img/chevron.png", "img"),
("packaging/psppt.desktop", "."),
]
a = Analysis(
["run.py"],
pathex=[],
binaries=collect_dynamic_libs("curl_cffi"),
datas=datas,
hiddenimports=collect_submodules("curl_cffi"),
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["tkinter", "unittest", "pydoc"],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="psppt",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
contents_directory="lib",
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="psppt",
)
+7
View File
@@ -0,0 +1,7 @@
# Build dependencies (pinned for reproducible CI builds).
# PySide6 is pinned to 6.8.3: newer 6.11.x bundles a Qt6Core.dll that imports
# icuuc.dll on Windows, which is not shipped and crashes at startup.
pyinstaller>=6.11
PySide6==6.8.3
curl_cffi>=0.7
pycryptodome>=3.20
+1
View File
@@ -1,2 +1,3 @@
PySide6>=6.6
curl_cffi>=0.7
pycryptodome>=3.20
+10
View File
@@ -0,0 +1,10 @@
"""PyInstaller entry point.
Runs the GUI the same way ``python -m app.main`` does, but as a plain script
so PyInstaller can build it (relative imports inside ``app`` won't resolve when
a module file is executed directly, hence this top-level launcher).
"""
from app.main import main
if __name__ == "__main__":
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
# Wine-based Gitea Actions runner: builds Windows binaries on a Linux host.
#
# Jobs run in "host" mode inside this container, where a Windows CPython 3.12
# (running under Wine) plus PyInstaller produce the .exe. Node.js and git are
# included so the stock JS actions (actions/checkout, actions/upload-artifact)
# work as-is.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive \
WINEPREFIX=/root/.wine \
WINEDEBUG=-all \
WINEDLLOVERRIDES="mscoree,mshtml=" \
PYTHON_VERSION=3.12.8
# WineHQ wine-staging (newer than Debian's 8.0, needed for Python 3.12),
# node + git for JS actions, and build/package tooling.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl wget gnupg2 \
&& mkdir -p /etc/apt/keyrings \
&& wget -qO- https://dl.winehq.org/wine-builds/winehq.key | gpg --dearmor -o /etc/apt/keyrings/winehq.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/winehq.gpg] https://dl.winehq.org/wine-builds/debian/ bookworm main" > /etc/apt/sources.list.d/winehq.list \
&& apt-get update \
&& apt-get install -y --install-recommends winehq-staging \
&& apt-get install -y --no-install-recommends unzip zip xvfb xauth cabextract nodejs git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# winetricks (standalone script; not packaged in Debian main).
RUN wget -qO /usr/local/bin/winetricks \
"https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks" \
&& chmod +x /usr/local/bin/winetricks
# Gitea Actions runner (linux-amd64 binary).
RUN wget -qO /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v0.6.1/act_runner-0.6.1-linux-amd64" \
&& chmod +x /usr/local/bin/act_runner
# MSVC runtime required by PySide6's Qt DLLs (also initializes the Wine prefix).
RUN xvfb-run -a winetricks --unattended vcrun2022
# Windows CPython 3.12 (embeddable zip) + pip.
RUN wget -qO /tmp/pyembed.zip \
"https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
&& mkdir -p /root/.wine/drive_c/Python312 \
&& cd /root/.wine/drive_c/Python312 \
&& unzip -q /tmp/pyembed.zip \
&& rm -f /tmp/pyembed.zip \
&& printf 'python312.zip\n.\nimport site\nLib\\site-packages\n' > python312._pth \
&& wine "C:\\Python312\\python.exe" --version
# Convenience wrapper: winpython -> wine C:\Python312\python.exe
RUN printf '#!/bin/sh\nexec wine "C:\\Python312\\python.exe" "$@"\n' > /usr/local/bin/winpython \
&& chmod +x /usr/local/bin/winpython
# Bootstrap pip, then pre-install the Windows build deps so CI jobs start fast.
RUN wget -qO /tmp/get-pip.py https://bootstrap.pypa.io/get-pip.py \
&& wine "C:\\Python312\\python.exe" /tmp/get-pip.py \
&& rm -f /tmp/get-pip.py
COPY requirements-build.txt /tmp/requirements-build.txt
RUN winpython -m pip install --upgrade pip \
&& winpython -m pip install -r /tmp/requirements-build.txt \
&& rm -f /tmp/requirements-build.txt
COPY runner/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
# Register on first run, then run the daemon (host mode).
if [ ! -f /data/.runner ]; then
echo "Registering Wine runner with Gitea…"
act_runner register --no-interactive \
--instance "${GITEA_INSTANCE_URL:?GITEA_INSTANCE_URL is required}" \
--token "${GITEA_RUNNER_REGISTRATION_TOKEN:?GITEA_RUNNER_REGISTRATION_TOKEN is required}" \
--name "${RUNNER_NAME:-windows-builder}" \
--labels "${RUNNER_LABELS:-windows:host}"
fi
exec act_runner daemon
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Package the offline cover zips into a single distributable bundle.
Outputs ``psppt-covers.zip`` containing ``libretro-covers.zip`` and
``openvgdb-covers.zip``. Attach it to the Gitea ``covers`` release; the app
downloads it on first run when the user opts in.
Usage: python3 scripts/make-covers-bundle.py [out-path]
"""
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
COVERS = ROOT / "data" / "covers"
INNER = ("libretro-covers.zip", "openvgdb-covers.zip")
def main(argv):
out = Path(argv[1]) if len(argv) > 1 else ROOT / "psppt-covers.zip"
missing = [n for n in INNER if not (COVERS / n).exists()]
if missing:
print(f"missing: {', '.join(missing)} in {COVERS}", file=sys.stderr)
return 2
# Inner zips are already compressed; store them to avoid recompressing.
with zipfile.ZipFile(out, "w", zipfile.ZIP_STORED) as z:
for n in INNER:
print(f"adding {n} …")
z.write(COVERS / n, arcname=n)
size = out.stat().st_size / (1 << 20)
print(f"wrote {out} ({size:.0f} MB)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))