Compare commits

..
20 Commits
Author SHA1 Message Date
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
27 changed files with 1454 additions and 386 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=
+56
View File
@@ -0,0 +1,56 @@
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 psp-preserve.spec
- name: Package tarball
run: tar -czf psp-preserve-linux-x86_64.tar.gz -C dist psp-preserve
- uses: actions/upload-artifact@v3
with:
name: psp-preserve-linux-x86_64
path: psp-preserve-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 psp-preserve.spec
- name: Package zip
run: |
cd dist && zip -rq ../psp-preserve-windows-x64.zip psp-preserve
- uses: actions/upload-artifact@v3
with:
name: psp-preserve-windows-x64
path: psp-preserve-windows-x64.zip
+8 -1
View File
@@ -16,4 +16,11 @@ data/*.pbp
data/ps1_templates/
# nps tsv files
data/nps/*.tsv
data/nps/*.tsv
# Local environment overrides (LAN paths, secrets). See .env.example
.env
# PyInstaller output
build/
dist/
+102 -32
View File
@@ -3,15 +3,16 @@ the name is terrible, i know.
![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.
* Dark theme with a blue accent — the Library tab in cover view. *
## Features
@@ -35,9 +36,9 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
## Requirements
- Linux + Python 3
- `PySide6` + `pycryptodome` (`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
@@ -58,6 +59,75 @@ 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 psp-preserve.spec
# output: dist/psp-preserve/ → run dist/psp-preserve/psp-preserve
```
Notes:
- The build bundles the DATs, PS1 loader, and the offline cover bundles. The
cover zips are large (~850 MB), so builds are slow and the output is big.
- `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/`:
@@ -66,14 +136,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
@@ -105,19 +176,18 @@ Discs are matched by hash first (most precise), then serial, then filename.
- `data/ps1_templates/` — optional PS1 Classics art templates
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
to drawn art when they're absent.
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan; drop
`PSP_GAMES.tsv` / `PSX_GAMES.tsv` manually to work offline).
- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept
only as an optional fallback decryptor; the app normally uses its own
pure-Python decryptor (`app/pkg.py`) and never needs Wine.
See `data/bin/ATTRIBUTION.md`.
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
- `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/psp-preserve/` on Linux, `%LOCALAPPDATA%\PSPPreserve`
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
@@ -136,7 +206,7 @@ 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 |
@@ -144,9 +214,9 @@ QSettings under `PSPPreserve`) taking precedence where noted.
| `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie |
| `PSP_NPS_DIR` | `<state>/nps` | NoPayStation output folder |
| `PSP_NPS_TSV_BASE` | `https://nopaystation.com/tsv/` | NoPayStation TSV base URL |
| `PSP_PKG2ZIP_PATH` | *(empty)* | pkg2zip binary path (else auto-detected) |
| `PSP_API_TOKEN` | *(empty)* | server API bearer token (unset = open) |
| `PSP_CREDENTIALS` | `$PSP_DATA_DIR/credentials.json` | credentials file |
| `PSP_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) |
@@ -235,7 +305,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
@@ -287,8 +357,7 @@ 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` install needed. A bundled `pkg2zip.exe` is kept as a fallback
only, and only runs if Python decryption fails and Wine is present.
`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**:
@@ -354,13 +423,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/psp-preserve/
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)
+43 -8
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,9 +37,12 @@ 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) / "PSPPreserve"
xdg = os.environ.get("XDG_STATE_HOME")
base = Path(xdg) if xdg else Path.home() / ".local" / "state"
return base / "psp-preserve"
@@ -23,14 +51,24 @@ def _default_state_dir():
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
@@ -51,9 +89,6 @@ ARCHIVE_LOGGED_IN_USER = os.environ.get("PSP_ARCHIVE_LOGGED_IN_USER", "")
NPS_OUTPUT_DIR = Path(os.environ.get("PSP_NPS_DIR", str(STATE_DIR / "nps")))
NPS_TSV_BASE = os.environ.get("PSP_NPS_TSV_BASE", "https://nopaystation.com/tsv/")
# pkg2zip binary used to decrypt/extract NPS .pkg files (blank = auto-detect).
PKG2ZIP_PATH = os.environ.get("PSP_PKG2ZIP_PATH", "")
# Scan roots (used by the scanner phase). Comma-separated in env.
GAMES_ROOTS = [Path(p) for p in os.environ.get("PSP_GAMES_ROOTS", "/data/umd_games").split(",") if p.strip()]
VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()]
+38 -20
View File
@@ -52,7 +52,7 @@ _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"
_MANUAL_DIR = config.USER_DATA_DIR / "manual_covers"
_OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip"
_LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip"
@@ -411,6 +411,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 +424,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:
@@ -808,7 +811,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 +937,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 +980,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 +992,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 +1034,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))
+608 -175
View File
File diff suppressed because it is too large Load Diff
+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)
+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.
+5 -92
View File
@@ -9,14 +9,12 @@ folder — no Wine or ``pkg2zip`` required.
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from typing import List
from . import config
@@ -56,7 +54,7 @@ def tsv_url(content_type: str, base: str = "") -> str:
def _tsv_dir() -> Path:
d = config.DATA_DIR / "nps"
d = config.USER_DATA_DIR / "nps"
d.mkdir(parents=True, exist_ok=True)
return d
@@ -167,31 +165,6 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool
raise
def find_pkg2zip(configured_path: str = "") -> Optional[Path]:
"""Locate pkg2zip: configured path, then the bundled binary, then PATH."""
if configured_path:
p = Path(configured_path)
return p if p.is_file() else None
bundled = config.DATA_DIR / "bin" / "pkg2zip.exe"
if bundled.is_file():
return bundled
found = shutil.which("pkg2zip")
return Path(found) if found else None
def _pkg2zip_cmd(pkg2zip: Path) -> List[str]:
"""Command prefix for pkg2zip, wrapping the bundled .exe in Wine on Linux."""
if pkg2zip.suffix.lower() == ".exe" and os.name != "nt":
wine = shutil.which("wine") or shutil.which("wine64")
if not wine:
raise RuntimeError(
"The bundled pkg2zip is a Windows binary and Wine is not installed — "
"install Wine (or point Settings → Store at a native pkg2zip)."
)
return [wine, str(pkg2zip)]
return [str(pkg2zip)]
def _safe_folder(value: str) -> str:
import re
cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
@@ -211,12 +184,11 @@ def _ensure_keys_bin(folder, kind):
pass
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
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 / pkg2zip needed); falls
back to pkg2zip only if that fails and a binary is available. Returns the
Uses the bundled pure-Python decryptor (no Wine or pkg2zip). Returns the
destination game folder path.
"""
from . import pkg as pkgmod
@@ -227,7 +199,6 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
if dest.is_dir() and any(dest.iterdir()):
return dest
# 1. Pure-Python decryptor (primary).
try:
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
progress_cb, cancel_event)
@@ -236,62 +207,4 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
except TransferCancelled:
raise
except Exception as exc: # noqa: BLE001
py_exc = exc
log.warning("python pkg decrypt failed (%s); trying pkg2zip fallback", exc)
# 2. pkg2zip fallback (needs a binary; usually Wine + the bundled .exe).
if pkg2zip is None:
pkg2zip = find_pkg2zip()
if pkg2zip is None:
raise RuntimeError(
f"could not decrypt {pkg_path.name} (no pkg2zip available): {py_exc}"
)
folder_name = _safe_folder(title_id)
tmp = Path(dest_dir).with_name(".nps-tmp-" + folder_name)
if tmp.exists():
shutil.rmtree(tmp, ignore_errors=True)
tmp.mkdir(parents=True, exist_ok=True)
if progress_cb is not None:
progress_cb("Extracting", 0.0, "")
try:
cmd = _pkg2zip_cmd(Path(pkg2zip)) + ["-x", str(pkg_path)]
env = dict(os.environ)
env["WINEDEBUG"] = "-all" # silence Wine's console chatter
proc = subprocess.Popen(
cmd,
cwd=str(tmp), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env,
)
assert proc.stdout is not None
for _line in proc.stdout:
if cancel_event is not None and cancel_event.is_set():
proc.kill()
raise TransferCancelled()
proc.wait()
if proc.returncode != 0:
raise RuntimeError(f"pkg2zip failed (exit {proc.returncode}) for {pkg_path.name}")
finally:
if cancel_event is not None and cancel_event.is_set():
shutil.rmtree(tmp, ignore_errors=True)
raise TransferCancelled()
eboots = list(tmp.rglob("EBOOT.PBP"))
if not eboots:
shutil.rmtree(tmp, ignore_errors=True)
raise RuntimeError(f"pkg2zip produced no EBOOT.PBP from {pkg_path.name}")
src_folder = eboots[0].parent
dest.mkdir(parents=True, exist_ok=True)
for p in sorted(src_folder.rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(src_folder)
target = dest / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(p, target)
if progress_cb is not None:
progress_cb("Extracting", 1.0, "")
shutil.rmtree(tmp, ignore_errors=True)
return dest
raise RuntimeError(f"could not decrypt {pkg_path.name}: {exc}") from exc
+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)
+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"
+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))
+3 -7
View File
@@ -476,11 +476,10 @@ class NoPayStationSource(Source):
label = "NoPayStation"
def __init__(self, content_type, tsv_url="", pkg2zip_path="", force=False):
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.pkg2zip = pkg2zip_path or ""
self.force = force
def scan(self, root=None):
@@ -492,10 +491,7 @@ class NoPayStationSource(Source):
dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly;
# pkg2zip is only an optional fallback if a binary happens to be
# configured (and only supports games/PSX).
pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip)
# 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-"))
@@ -505,7 +501,7 @@ class NoPayStationSource(Source):
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, pkg2zip,
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)
-38
View File
@@ -1,38 +0,0 @@
# pkg2zip
The `pkg2zip.exe` binary in this folder is kept as an optional fallback for
decrypting NoPayStation `.pkg` files (PSP / PSX / PS3 / PSVita content). The
app normally uses its own pure-Python decryptor (`app/pkg.py`) and only runs
this binary if that fails.
- Project: <https://github.com/mmozeiko/pkg2zip>
- Author: Marcin Mikołajczak (mmozeiko)
- Version: v1.8 (bundled)
- License: MIT
The Windows binary is run through Wine on Linux/macOS (see
`app/nopaystation.py`).
## License
MIT License
Copyright (c) 2017 Marcin Mikołajczak (mmozeiko)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
+6 -6
View File
@@ -6,7 +6,7 @@ services:
# 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: 273 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

After

Width:  |  Height:  |  Size: 683 KiB

+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=PSP Preservation Tool
Comment=Preserve and manage your PSP library
Exec=psp-preserve
Icon=psp-preserve
Terminal=false
Categories=Utility;AudioVideo;
StartupWMClass=psp-preserve
+67
View File
@@ -0,0 +1,67 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec: build the PSP Preservation Tool (onedir).
Bundles the read-only resources (DATs, PS1 loader, offline cover bundles,
icons) under ``_internal/`` 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 psp-preserve.spec
Output: dist/psp-preserve/
"""
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-covers.zip", "data/covers"),
("data/covers/openvgdb-covers.zip", "data/covers"),
("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/psp-preserve.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="psp-preserve",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
contents_directory="psp-preserve",
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="psp-preserve",
)
+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
+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