Compare commits
29
Commits
318a920873
...
v0.1.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a3859a21 | ||
|
|
96e876b8ec | ||
|
|
733e3c9089 | ||
|
|
dc7d6c9720 | ||
|
|
5c5bb6e5d6 | ||
|
|
35db22e3c5 | ||
|
|
b0cdac5483 | ||
|
|
f367bbe298 | ||
|
|
c61d8e9d3f | ||
|
|
78f6d44c73 | ||
|
|
a7f90cd5a7 | ||
|
|
c9575817fe | ||
|
|
b576ff5747 | ||
|
|
6510229b07 | ||
|
|
e61d91418e | ||
|
|
a1c461f6aa | ||
|
|
985c8df2cc | ||
|
|
6eab2eca9c | ||
|
|
eac29c428d | ||
|
|
ce00e3f518 | ||
|
|
c19717402c | ||
|
|
1ec2e81e15 | ||
|
|
bd9cf47ca9 | ||
|
|
6e06d1389c | ||
|
|
8c5f9b6ea0 | ||
|
|
74164b33ec | ||
|
|
75db7a3faa | ||
|
|
ed3cc9bc8a | ||
|
|
e01013ce6a |
@@ -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=
|
||||||
@@ -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
|
||||||
+12
-1
@@ -16,4 +16,15 @@ data/*.pbp
|
|||||||
data/ps1_templates/
|
data/ps1_templates/
|
||||||
|
|
||||||
# nps tsv files
|
# nps tsv files
|
||||||
data/nps/*.tsv
|
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
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
# Port-forward 80 and 443 on your router to this host.
|
# Port-forward 80 and 443 on your router to this host.
|
||||||
|
|
||||||
hostname.com {
|
hostname.com {
|
||||||
reverse_proxy psp-preserve:8000
|
reverse_proxy psppt:8000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
# PSP Preservation Tool
|
# psppt
|
||||||
|
|
||||||
|
|
||||||
the name is terrible, i know.
|
the name is terrible, i know.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Native Linux GUI (PySide6) for preserving PSP UMD **games** and **videos** as
|
A cross-platform (Linux + Windows) desktop GUI — PySide6 — for preserving PSP
|
||||||
`.iso` files. It scans your discs, verifies them against the full Redump catalog
|
UMD **games** and **videos** as `.iso` files. It scans your discs, verifies them
|
||||||
(Redump DATs), reports what's missing, and retags/renames discs to a canonical
|
against the Redump/No-Intro catalogs, reports what's missing, and retags/renames
|
||||||
scheme. It also fetches cover art and metadata, syncs discs and save-data with a
|
discs to a canonical scheme. It fetches cover art and metadata, syncs discs and
|
||||||
real PSP, can pull missing discs from your own Redump 7z archive collection, and
|
save-data with a real PSP, can pull missing discs from a Redump 7z archive
|
||||||
converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
collection, downloads PSN content from NoPayStation, and converts PlayStation 1
|
||||||
|
ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
||||||
|
|
||||||

|

|
||||||
* Use default app theme for Qt.
|
* Dark theme with a blue accent — the Library tab in cover view. *
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -35,9 +40,9 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Linux + Python 3
|
- Python 3.10+ on Linux or Windows
|
||||||
- `PySide6` + `pycryptodome` (`pip install -r requirements.txt`)
|
- `PySide6`, `curl_cffi`, `pycryptodome` (`pip install -r requirements.txt`)
|
||||||
- `7z` (p7zip) — used to peek/extract disc images from store archives
|
- `7z` (p7zip / 7-Zip) — used to peek/extract disc images from store archives
|
||||||
- PPSSPP (optional) — to launch discs from the details panel
|
- PPSSPP (optional) — to launch discs from the details panel
|
||||||
|
|
||||||
NoPayStation `.pkg` files are decrypted by the bundled **pure-Python
|
NoPayStation `.pkg` files are decrypted by the bundled **pure-Python
|
||||||
@@ -58,6 +63,77 @@ pip install -r requirements.txt
|
|||||||
python3 -m app.main
|
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
|
## First-time setup
|
||||||
|
|
||||||
1. Put your DAT files in `data/dats/`:
|
1. Put your DAT files in `data/dats/`:
|
||||||
@@ -66,14 +142,15 @@ python3 -m app.main
|
|||||||
Redump lacks — keeps serial-based matching working).
|
Redump lacks — keeps serial-based matching working).
|
||||||
- videos: No-Intro `Sony - PlayStation Portable (UMD Video)` (Redump has
|
- videos: No-Intro `Sony - PlayStation Portable (UMD Video)` (Redump has
|
||||||
no UMD Video DAT).
|
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
|
```bash
|
||||||
python3 -m app.ingest
|
python3 -m app.ingest
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Open the GUI and point **Settings → General → Games/Videos directory** at
|
3. Point **Settings → General → Games/Videos directory** at your disc folders
|
||||||
your disc folders (or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`).
|
(or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`).
|
||||||
4. (Optional) PS1 conversion — copy any PSN PS1 classic `EBOOT.PBP` to
|
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
|
`data/base.PBP` (it supplies Sony's encrypted loader). Then set the **PS1
|
||||||
directory**, **PS1 output directory** and **PS1 base.pbp** in
|
directory**, **PS1 output directory** and **PS1 base.pbp** in
|
||||||
@@ -95,9 +172,10 @@ Discs are matched by hash first (most precise), then serial, then filename.
|
|||||||
- `data/credentials.json` — cover/metadata API keys (IGDB, TheMovieDB,
|
- `data/credentials.json` — cover/metadata API keys (IGDB, TheMovieDB,
|
||||||
ScreenScraper, Google Custom Search). Environment variables take precedence.
|
ScreenScraper, Google Custom Search). Environment variables take precedence.
|
||||||
- `data/manual_covers/games|videos/` — your own cover images.
|
- `data/manual_covers/games|videos/` — your own cover images.
|
||||||
- `data/covers/` — checked-in assets: the UMD video/game cover templates,
|
- `data/covers/` — checked-in assets: the UMD video/game cover templates and
|
||||||
`libretro-index.json` + `openvgdb-index.json` catalogs, `libretro-covers.zip` +
|
the `libretro-index.json` + `openvgdb-index.json` catalogs. The large
|
||||||
`openvgdb-covers.zip` offline cover bundles, and the legacy cover cache.
|
`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`
|
- `data/base.PBP` — a PSN PS1 classic EBOOT (any game) supplying `STARTDAT`
|
||||||
+ a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted).
|
+ a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted).
|
||||||
- `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS
|
- `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS
|
||||||
@@ -105,30 +183,29 @@ Discs are matched by hash first (most precise), then serial, then filename.
|
|||||||
- `data/ps1_templates/` — optional PS1 Classics art templates
|
- `data/ps1_templates/` — optional PS1 Classics art templates
|
||||||
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
|
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
|
||||||
to drawn art when they're absent.
|
to drawn art when they're absent.
|
||||||
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan; drop
|
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev;
|
||||||
`PSP_GAMES.tsv` / `PSX_GAMES.tsv` manually to work offline).
|
a packaged build stores them under the state dir instead).
|
||||||
- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept
|
- State dir (`~/.local/state/psppt/` on Linux, `%LOCALAPPDATA%\psppt`
|
||||||
only as an optional fallback decryptor; the app normally uses its own
|
on Windows, or `$PSP_STATE_DIR`):
|
||||||
pure-Python decryptor (`app/pkg.py`) and never needs Wine.
|
|
||||||
See `data/bin/ATTRIBUTION.md`.
|
|
||||||
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
|
|
||||||
- `library.sqlite` — the catalog + library database.
|
- `library.sqlite` — the catalog + library database.
|
||||||
- `covers/` — downloaded cover cache.
|
- `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
|
SQLite is kept on a local disk because SQLite locking is unreliable over
|
||||||
network filesystems; the DATs, manual covers and credentials stay in `data/`
|
network filesystems. Read-only resources (DATs, the POPS loader, icons) are
|
||||||
(which may be a network share).
|
bundled with the app; user-writable data lives in the state dir.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Everything is driven by environment variables, with GUI settings (stored in
|
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 |
|
| Variable | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PSP_DATA_DIR` | `./data` | data root (DATs, credentials, manual covers) |
|
| `PSP_DATA_DIR` | `./data` | data root (DATs, credentials, manual covers) |
|
||||||
| `PSP_DATS_DIR` | `$PSP_DATA_DIR/dats` | DAT folder |
|
| `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_DB_PATH` | `$PSP_STATE_DIR/library.sqlite` | SQLite library |
|
||||||
| `PSP_COVERS_DIR` | `$PSP_STATE_DIR/covers` | cover cache |
|
| `PSP_COVERS_DIR` | `$PSP_STATE_DIR/covers` | cover cache |
|
||||||
| `PSP_GAMES_ROOTS` | `/data/umd_games` | comma-separated game disc roots |
|
| `PSP_GAMES_ROOTS` | `/data/umd_games` | comma-separated game disc roots |
|
||||||
@@ -136,7 +213,7 @@ QSettings under `PSPPreserve`) taking precedence where noted.
|
|||||||
| `PSP_PS1_ROOTS` | `/data/ps1` | comma-separated PS1 disc roots |
|
| `PSP_PS1_ROOTS` | `/data/ps1` | comma-separated PS1 disc roots |
|
||||||
| `PSP_PS1_OUTPUT_DIR` | `/data/ps1_converted` | converted EBOOT.PBP output folder |
|
| `PSP_PS1_OUTPUT_DIR` | `/data/ps1_converted` | converted EBOOT.PBP output folder |
|
||||||
| `PSP_SCAN_WORKERS` | `1` | parallel hashing workers |
|
| `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_SERVER` | `https://psp.example.com` | store server base URL |
|
||||||
| `PSP_STORE_TOKEN` | *(empty)* | store server bearer token |
|
| `PSP_STORE_TOKEN` | *(empty)* | store server bearer token |
|
||||||
| `PSP_ARCHIVE_ITEM` | `psp-redump-roms321.com` | archive.org item identifier |
|
| `PSP_ARCHIVE_ITEM` | `psp-redump-roms321.com` | archive.org item identifier |
|
||||||
@@ -144,11 +221,12 @@ QSettings under `PSPPreserve`) taking precedence where noted.
|
|||||||
| `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie |
|
| `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie |
|
||||||
| `PSP_NPS_DIR` | `<state>/nps` | NoPayStation output folder |
|
| `PSP_NPS_DIR` | `<state>/nps` | NoPayStation output folder |
|
||||||
| `PSP_NPS_TSV_BASE` | `https://nopaystation.com/tsv/` | NoPayStation TSV base URL |
|
| `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_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_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_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`,
|
Cover/metadata keys can also be set as env vars: `IGDB_CLIENT_ID`,
|
||||||
`IGDB_CLIENT_SECRET`, `TMDB_API_KEY`, `GOOGLE_API_KEY`, `GOOGLE_CX`,
|
`IGDB_CLIENT_SECRET`, `TMDB_API_KEY`, `GOOGLE_API_KEY`, `GOOGLE_CX`,
|
||||||
@@ -181,12 +259,23 @@ Manual covers in `data/manual_covers/videos/` always win.
|
|||||||
|
|
||||||
### Offline bundles & auto-download
|
### Offline bundles & auto-download
|
||||||
|
|
||||||
The two bundles (`libretro-covers.zip`, `openvgdb-covers.zip`) are shipped in
|
The two bundles (`libretro-covers.zip`, `openvgdb-covers.zip`) are packaged
|
||||||
`data/covers/`, so a fresh install works with **no internet and no API keys**.
|
into one `psppt-covers.zip` (~850 MB) and attached to the repo's `covers`
|
||||||
If either is missing at startup, the app downloads it from the store server's
|
release — **not** bundled into the app binary.
|
||||||
`/api/covers/<name>.zip` endpoint (see [Server setup](#server-setup)).
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
python3 -m app.libretro # libretro-thumbnails -> libretro-covers.zip
|
python3 -m app.libretro # libretro-thumbnails -> libretro-covers.zip
|
||||||
@@ -235,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
|
`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`).
|
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
|
The **Store** tab downloads missing discs from a Redump 7z archive collection
|
||||||
(`Name (Region) (tags).7z`, containing the `.iso`/`.cso` inside). It has three
|
(`Name (Region) (tags).7z`, containing the `.iso`/`.cso` inside). It has three
|
||||||
@@ -287,8 +376,7 @@ Requirements & behaviour:
|
|||||||
|
|
||||||
1. `.pkg` files are decrypted by the built-in **pure-Python decryptor**
|
1. `.pkg` files are decrypted by the built-in **pure-Python decryptor**
|
||||||
(`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no
|
(`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no
|
||||||
`pkg2zip` install needed. A bundled `pkg2zip.exe` is kept as a fallback
|
`pkg2zip` needed.
|
||||||
only, and only runs if Python decryption fails and Wine is present.
|
|
||||||
2. Set **NoPayStation output dir** in **Settings → Store** (default
|
2. Set **NoPayStation output dir** in **Settings → Store** (default
|
||||||
`$PSP_STATE_DIR/nps`).
|
`$PSP_STATE_DIR/nps`).
|
||||||
3. Downloads land in the NPS folder and appear in the **Library**:
|
3. Downloads land in the NPS folder and appear in the **Library**:
|
||||||
@@ -354,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):
|
macvlan bridge so it gets its own LAN IP (e.g. `192.168.1.100`, plain HTTP on 8000):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# in /path/to/psp-preserve/ — adjust the volume paths for your shares
|
# in /path/to/psppt/
|
||||||
printf 'PSP_API_TOKEN=<long-random-secret>\n' > .env
|
cp .env.example .env # then edit: LAN IP, share paths, API token
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The compose file mounts your Unraid shares into `/data/store`, `/data/umd_games`
|
The compose file reads the LAN IP and Unraid share paths from `.env` and
|
||||||
and `/data/umd_videos` (the store is mounted read-only).
|
mounts them into `/data/store`, `/data/umd_games` and `/data/umd_videos`
|
||||||
|
(the store is mounted read-only).
|
||||||
|
|
||||||
### TLS (optional)
|
### TLS (optional)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
"""PSP Preservation Tool application package."""
|
"""psppt application package."""
|
||||||
|
|||||||
+2
-2
@@ -251,7 +251,7 @@ def _library(conn, system=None, refresh=False):
|
|||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "PSPPreserve/0.1"
|
server_version = "psppt/0.1"
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
@@ -262,7 +262,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if path == "/":
|
if path == "/":
|
||||||
_json(self, {
|
_json(self, {
|
||||||
"name": "PSP Preservation Tool API",
|
"name": "psppt API",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"/api/summary",
|
"/api/summary",
|
||||||
"/api/releases?system=games|videos&q=&limit=&offset=",
|
"/api/releases?system=games|videos&q=&limit=&offset=",
|
||||||
|
|||||||
+51
-9
@@ -1,8 +1,33 @@
|
|||||||
"""Central configuration, driven by environment variables."""
|
"""Central configuration, driven by environment variables."""
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
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")))
|
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")))
|
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).
|
"""Local state directory (fast, reliable local disk).
|
||||||
|
|
||||||
SQLite locking is unreliable over network filesystems (SMB/FUSE), so the
|
SQLite locking is unreliable over network filesystems (SMB/FUSE), so the
|
||||||
library DB and the cover cache live on local storage while DATs, manual
|
library DB and the cover cache live on local storage while read-only DATs
|
||||||
covers and credentials stay in ``DATA_DIR`` (which may be a network share).
|
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")
|
xdg = os.environ.get("XDG_STATE_HOME")
|
||||||
base = Path(xdg) if xdg else Path.home() / ".local" / "state"
|
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())))
|
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")))
|
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")))
|
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_DB_PATH = DATA_DIR / "library.sqlite"
|
||||||
LEGACY_COVERS_DIR = DATA_DIR / "covers"
|
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.
|
# 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.
|
# 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", "")
|
STORE_TOKEN = os.environ.get("PSP_STORE_TOKEN", "")
|
||||||
|
|
||||||
# Optional direct URLs for the offline cover bundle zips. When unset, the GUI
|
# 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", "")
|
LIBRETRO_COVERS_URL = os.environ.get("PSP_LIBRETRO_COVERS_URL", "")
|
||||||
OVGDB_COVERS_URL = os.environ.get("PSP_OVGDB_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.org item hosting the same Redump 7z set (identifier or full URL).
|
||||||
ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com")
|
ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com")
|
||||||
|
|
||||||
@@ -51,9 +96,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_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/")
|
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.
|
# 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()]
|
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()]
|
VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()]
|
||||||
|
|||||||
+113
-55
@@ -52,9 +52,21 @@ _IGDB_IMAGE = "https://images.igdb.com/igdb/image/upload/"
|
|||||||
_TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
|
_TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
|
||||||
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
|
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
|
||||||
_GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1"
|
_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"
|
# Offline cover bundles live in the writable cover dir (downloaded on demand),
|
||||||
_LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip"
|
# 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_SEARCH = "https://en.wikipedia.org/w/api.php"
|
||||||
_WIKI_SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
|
_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"
|
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||||
_JPEG_MAGIC = b"\xff\xd8\xff"
|
_JPEG_MAGIC = b"\xff\xd8\xff"
|
||||||
_MAX_HOPS = 3 # follow libretro symlink text bodies at most this many times
|
_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_token_state = {"token": "", "expires": 0.0} # IGDB OAuth2 token cache
|
||||||
_igdb_lock = threading.Lock()
|
_igdb_lock = threading.Lock()
|
||||||
_igdb_last = 0.0 # monotonic clock of the last IGDB API call
|
_igdb_last = 0.0 # monotonic clock of the last IGDB API call
|
||||||
@@ -158,48 +170,74 @@ def clear_cover_cache():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _download_file(url, dest, timeout=600):
|
def _download_file(url, dest, timeout=600, progress_cb=None, cancel_event=None):
|
||||||
"""Stream `url` to `dest` (atomic); returns True on success."""
|
"""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)
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
return False
|
return False
|
||||||
|
total = int(resp.headers.get("Content-Length") or 0)
|
||||||
tmp = dest.with_name(dest.name + ".tmp")
|
tmp = dest.with_name(dest.name + ".tmp")
|
||||||
|
done = 0
|
||||||
with open(tmp, "wb") as f:
|
with open(tmp, "wb") as f:
|
||||||
while True:
|
while True:
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
raise InterruptedError("download cancelled")
|
||||||
chunk = resp.read(1 << 20)
|
chunk = resp.read(1 << 20)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
done += len(chunk)
|
||||||
|
if progress_cb is not None:
|
||||||
|
progress_cb(done, total)
|
||||||
os.replace(tmp, dest)
|
os.replace(tmp, dest)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def ensure_cover_bundles(server_url=""):
|
def covers_bundle_present():
|
||||||
"""Download missing offline cover bundle zips from the store server.
|
"""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
|
def download_covers_bundle(url=None, progress_cb=None, cancel_event=None):
|
||||||
is set in config. Returns the names of bundles downloaded.
|
"""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("/")
|
url = (url or config.COVERS_BUNDLE_URL or "").strip()
|
||||||
downloaded = []
|
if not url:
|
||||||
for name, path, direct in (
|
raise ValueError("no cover bundle URL configured")
|
||||||
("libretro-covers.zip", _LIBRETRO_ZIP, config.LIBRETRO_COVERS_URL),
|
config.COVERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
("openvgdb-covers.zip", _OVGDB_ZIP, config.OVGDB_COVERS_URL),
|
_download_file(url, _BUNDLE_ZIP, timeout=3600,
|
||||||
):
|
progress_cb=progress_cb, cancel_event=cancel_event)
|
||||||
if path.exists():
|
inner = ("libretro-covers.zip", "openvgdb-covers.zip")
|
||||||
continue
|
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
|
||||||
url = direct or (f"{base}/api/covers/{name}" if base else "")
|
missing = [n for n in inner if n not in z.namelist()]
|
||||||
if not url:
|
if missing:
|
||||||
continue
|
raise ValueError(f"cover bundle is missing: {', '.join(missing)}")
|
||||||
try:
|
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
|
||||||
if _download_file(url, path):
|
for name in inner:
|
||||||
downloaded.append(name)
|
out = config.COVERS_DIR / name
|
||||||
log.info("downloaded offline cover bundle %s", name)
|
tmp = out.with_name(out.name + ".tmp")
|
||||||
except Exception as exc: # noqa: BLE001
|
with z.open(name) as src, open(tmp, "wb") as dst:
|
||||||
log.warning("could not download cover bundle %s: %s", name, exc)
|
while True:
|
||||||
return downloaded
|
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"):
|
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)
|
data = fetch_libretro_local(name)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
return data
|
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:
|
if not indexed_only:
|
||||||
# 1. Cheap: exact + progressively stripped names (no index needed).
|
# 1. Cheap: exact + progressively stripped names (no index needed).
|
||||||
for kind in _LIBRETRO_KINDS:
|
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
|
# 2. Fuzzy/exact via the precomputed index (covers exact names too, so the
|
||||||
# bulk loader skips the wasteful exact pass entirely).
|
# bulk loader skips the wasteful exact pass entirely).
|
||||||
for kind in _LIBRETRO_KINDS:
|
for kind in _LIBRETRO_KINDS:
|
||||||
best = _index_best(name, kind, system)
|
best = _index_best(name, kind, idx_system)
|
||||||
if best:
|
if best:
|
||||||
data = _fetch_png(best, kind, timeout, system)
|
data = _fetch_png(best, kind, timeout, system)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
@@ -479,9 +520,10 @@ def _ovgdb_name_list():
|
|||||||
global _ovgdb_names
|
global _ovgdb_names
|
||||||
if _ovgdb_names is None:
|
if _ovgdb_names is None:
|
||||||
_ovgdb_names = set()
|
_ovgdb_names = set()
|
||||||
if _OVGDB_ZIP.exists():
|
path = _bundle_zip_path("openvgdb-covers.zip")
|
||||||
|
if path.exists():
|
||||||
try:
|
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")}
|
_ovgdb_names = {n[:-4] for n in z.namelist() if n.endswith(".jpg")}
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
@@ -532,7 +574,7 @@ def fetch_openvgdb_local(name):
|
|||||||
if entry is None:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(_OVGDB_ZIP) as z:
|
with zipfile.ZipFile(_bundle_zip_path("openvgdb-covers.zip")) as z:
|
||||||
data = z.read(entry)
|
data = z.read(entry)
|
||||||
return data if _is_image(data) else None
|
return data if _is_image(data) else None
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -556,7 +598,7 @@ def fetch_libretro_local(name):
|
|||||||
if entry is None:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
|
with zipfile.ZipFile(_bundle_zip_path("libretro-covers.zip")) as z:
|
||||||
data = z.read(entry)
|
data = z.read(entry)
|
||||||
return data if _is_image(data) else None
|
return data if _is_image(data) else None
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -567,9 +609,10 @@ def _libretro_name_list():
|
|||||||
global _libretro_names
|
global _libretro_names
|
||||||
if _libretro_names is None:
|
if _libretro_names is None:
|
||||||
_libretro_names = set()
|
_libretro_names = set()
|
||||||
if _LIBRETRO_ZIP.exists():
|
path = _bundle_zip_path("libretro-covers.zip")
|
||||||
|
if path.exists():
|
||||||
try:
|
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")}
|
_libretro_names = {n[:-4] for n in z.namelist() if n.endswith(".png")}
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
@@ -607,7 +650,7 @@ def _fetch_screenscraper(name, timeout, system="games"):
|
|||||||
params = {
|
params = {
|
||||||
"devid": devid,
|
"devid": devid,
|
||||||
"devpassword": devpassword,
|
"devpassword": devpassword,
|
||||||
"softname": creds.get("screenscraper_softname") or "psp-preserve",
|
"softname": creds.get("screenscraper_softname") or "psppt",
|
||||||
"output": "json",
|
"output": "json",
|
||||||
"systemeid": _platform(system)["ss_id"],
|
"systemeid": _platform(system)["ss_id"],
|
||||||
"romtype": "rom",
|
"romtype": "rom",
|
||||||
@@ -808,7 +851,7 @@ def compose_game_cover(key_art_bytes):
|
|||||||
return bytes(ba)
|
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):
|
def _ps1_template_bytes(name):
|
||||||
@@ -934,25 +977,28 @@ def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor,
|
|||||||
QRectF, Qt, _save):
|
QRectF, Qt, _save):
|
||||||
W, H = 310, 180
|
W, H = 310, 180
|
||||||
img = QImage(W, H, QImage.Format_ARGB32)
|
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 = QLinearGradient(0, 0, 0, H)
|
||||||
grad.setColorAt(0.0, QColor(38, 38, 40))
|
grad.setColorAt(0.0, QColor(38, 38, 40))
|
||||||
grad.setColorAt(1.0, QColor(12, 12, 14))
|
grad.setColorAt(1.0, QColor(12, 12, 14))
|
||||||
|
card = QPainterPath()
|
||||||
|
card.addRoundedRect(QRectF(0, 0, W, H), 12, 12)
|
||||||
|
|
||||||
p = QPainter(img)
|
p = QPainter(img)
|
||||||
p.setRenderHint(QPainter.Antialiasing, True)
|
p.setRenderHint(QPainter.Antialiasing, True)
|
||||||
p.setRenderHint(QPainter.TextAntialiasing, 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.setPen(QPen(QColor(110, 110, 115), 1))
|
||||||
p.setBrush(Qt.NoBrush)
|
p.setBrush(Qt.NoBrush)
|
||||||
p.drawRoundedRect(QRectF(4, 4, W - 8, H - 8), 9, 9)
|
p.drawPath(card)
|
||||||
|
|
||||||
# PlayStation button glyphs, top-right.
|
# PlayStation button glyphs, top-right.
|
||||||
_draw_ps_glyphs(p, W - 34, 12, 22, QColor(200, 200, 205), QRectF, QPointF,
|
_draw_ps_glyphs(p, W - 34, 12, 22, QRectF, QPointF, QPolygonF, QPen, Qt,
|
||||||
QPolygonF, QPen, Qt)
|
QColor)
|
||||||
|
|
||||||
# Title (bold, upper-left). Shrink the font until the whole title fits, so
|
# 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.
|
# 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,
|
p.drawText(QRectF(18, 16, title_width, 28), Qt.AlignLeft | Qt.AlignVCenter,
|
||||||
title_text)
|
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).
|
# Description (wrapped to at most 3 lines, last line elided).
|
||||||
if description:
|
if description:
|
||||||
f_desc = QFont("DejaVu Sans")
|
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))
|
p.setPen(QColor(215, 215, 215))
|
||||||
fm = QFontMetrics(f_desc)
|
fm = QFontMetrics(f_desc)
|
||||||
lines = _wrap_elided(fm, description.strip(), W - 36, 3, Qt)
|
lines = _wrap_elided(fm, description.strip(), W - 36, 3, Qt)
|
||||||
y = 46
|
y = 52
|
||||||
for line in lines:
|
for line in lines:
|
||||||
p.drawText(QRectF(18, y, W - 36, 16), Qt.AlignLeft | Qt.AlignVCenter, line)
|
p.drawText(QRectF(18, y, W - 36, 16), Qt.AlignLeft | Qt.AlignVCenter, line)
|
||||||
y += 16
|
y += 16
|
||||||
@@ -1024,23 +1074,31 @@ def _wrap_elided(fm, text, width, max_lines, Qt):
|
|||||||
return lines[:max_lines]
|
return lines[:max_lines]
|
||||||
|
|
||||||
|
|
||||||
def _draw_ps_glyphs(p, x, y, size, color, QRectF, QPointF, QPolygonF, QPen, Qt):
|
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."""
|
"""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
|
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)
|
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)])
|
tri = QPolygonF([QPointF(x, y + cell), QPointF(x + cell, y), QPointF(x + cell, y + cell)])
|
||||||
p.drawPolygon(tri)
|
p.drawPolygon(tri)
|
||||||
# Circle (top-right).
|
# Circle (top-right, red).
|
||||||
|
p.setPen(_pen(QColor("#DE1C18")))
|
||||||
p.drawEllipse(QRectF(x + cell, y, cell, cell))
|
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 + 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))
|
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))
|
p.drawRect(QRectF(x + cell, y + cell, cell, cell))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+817
-208
File diff suppressed because it is too large
Load Diff
@@ -88,6 +88,42 @@ def ingest_dat(conn, path):
|
|||||||
return dat_name, system, release_count, rom_count
|
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):
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description="Ingest clrmamepro DAT files into SQLite")
|
parser = argparse.ArgumentParser(description="Ingest clrmamepro DAT files into SQLite")
|
||||||
parser.add_argument("--dats", help="directory containing .dat files (default: data/dats)")
|
parser.add_argument("--dats", help="directory containing .dat files (default: data/dats)")
|
||||||
|
|||||||
+4
-2
@@ -48,8 +48,10 @@ def launch_ppsspp(iso, exe=""):
|
|||||||
else:
|
else:
|
||||||
return False, "PPSSPP not found. Install it or set its path in Settings → Saves."
|
return False, "PPSSPP not found. Install it or set its path in Settings → Saves."
|
||||||
try:
|
try:
|
||||||
subprocess.Popen(cmd, start_new_session=True, env=_env(),
|
kwargs = {"env": _env(), "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
||||||
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, ""
|
return True, ""
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return False, str(exc)
|
return False, str(exc)
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
"""PSP Preservation Tool entry point (native GUI).
|
"""psppt entry point (native GUI).
|
||||||
|
|
||||||
Run with::
|
Run with::
|
||||||
|
|
||||||
|
|||||||
+35
-1
@@ -111,6 +111,36 @@ def normalize_name(name):
|
|||||||
return _normalize(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):
|
def serial_candidates(conn, record):
|
||||||
"""Return all catalog releases matching the disc's serial (size-narrowed)."""
|
"""Return all catalog releases matching the disc's serial (size-narrowed)."""
|
||||||
serial = record.get("serial")
|
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"))
|
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")
|
size = record.get("size")
|
||||||
|
|
||||||
# 1. Hash is the most precise key; serials are shared across revisions.
|
# 1. Hash is the most precise key; serials are shared across revisions.
|
||||||
@@ -171,6 +201,10 @@ def match(conn, record):
|
|||||||
for cand in candidates:
|
for cand in candidates:
|
||||||
if cand["id"] == saved["release_id"]:
|
if cand["id"] == saved["release_id"]:
|
||||||
return "choice", cand
|
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
|
return "unknown", None
|
||||||
|
|
||||||
# 4. Fuzzy fallback (filename + PARAM.SFO title), size-disambiguated.
|
# 4. Fuzzy fallback (filename + PARAM.SFO title), size-disambiguated.
|
||||||
|
|||||||
+6
-93
@@ -9,14 +9,12 @@ folder — no Wine or ``pkg2zip`` required.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ def tsv_url(content_type: str, base: str = "") -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _tsv_dir() -> Path:
|
def _tsv_dir() -> Path:
|
||||||
d = config.DATA_DIR / "nps"
|
d = config.USER_DATA_DIR / "nps"
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@@ -153,7 +151,7 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "psp-preserve/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "psppt/0.1"})
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
data = resp.read()
|
data = resp.read()
|
||||||
text = data.decode("utf-8", errors="replace")
|
text = data.decode("utf-8", errors="replace")
|
||||||
@@ -167,31 +165,6 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool
|
|||||||
raise
|
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:
|
def _safe_folder(value: str) -> str:
|
||||||
import re
|
import re
|
||||||
cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
|
cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
|
||||||
@@ -211,12 +184,11 @@ def _ensure_keys_bin(folder, kind):
|
|||||||
pass
|
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:
|
kind_hint: str = None, progress_cb=None, cancel_event=None) -> Path:
|
||||||
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
|
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
|
||||||
|
|
||||||
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
|
Uses the bundled pure-Python decryptor (no Wine or pkg2zip). Returns the
|
||||||
back to pkg2zip only if that fails and a binary is available. Returns the
|
|
||||||
destination game folder path.
|
destination game folder path.
|
||||||
"""
|
"""
|
||||||
from . import pkg as pkgmod
|
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()):
|
if dest.is_dir() and any(dest.iterdir()):
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
# 1. Pure-Python decryptor (primary).
|
|
||||||
try:
|
try:
|
||||||
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
|
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
|
||||||
progress_cb, cancel_event)
|
progress_cb, cancel_event)
|
||||||
@@ -236,62 +207,4 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
|||||||
except TransferCancelled:
|
except TransferCancelled:
|
||||||
raise
|
raise
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
py_exc = exc
|
raise RuntimeError(f"could not decrypt {pkg_path.name}: {exc}") from 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
|
|
||||||
|
|||||||
+1
-1
@@ -155,7 +155,7 @@ def scrape(limit=None, workers=8, rate=3.0):
|
|||||||
log.info("downloading OpenVGDB dump (%s)…", OVGDB_ZIP_URL)
|
log.info("downloading OpenVGDB dump (%s)…", OVGDB_ZIP_URL)
|
||||||
tmpdir = Path(tempfile.mkdtemp(prefix="openvgdb-"))
|
tmpdir = Path(tempfile.mkdtemp(prefix="openvgdb-"))
|
||||||
dump_zip = tmpdir / "openvgdb.zip"
|
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:
|
with urllib.request.urlopen(req, timeout=60) as r, open(dump_zip, "wb") as f:
|
||||||
f.write(r.read())
|
f.write(r.read())
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,9 +2,9 @@
|
|||||||
from . import iso_reader, matcher, namer
|
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."""
|
"""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)
|
return identify_with_release(record, release, method)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -40,6 +40,7 @@ import io
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
|
import sys
|
||||||
import zlib
|
import zlib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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
|
# (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
|
# the base PBP's encrypted ~PSP loader which frequently fails to link on
|
||||||
# 6.60/6.61 (error 80020148 "game could not be started").
|
# 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_DATA_PSP = _LOADER_DIR / "DATA.PSP"
|
||||||
BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN"
|
BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
CREDENTIALS_PATH = Path(
|
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 = [
|
FIELDS = [
|
||||||
|
|||||||
+316
@@ -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))
|
||||||
+5
-9
@@ -284,7 +284,7 @@ class RemoteStoreSource(Source):
|
|||||||
self.token = token or ""
|
self.token = token or ""
|
||||||
|
|
||||||
def _headers(self):
|
def _headers(self):
|
||||||
headers = {"User-Agent": "psp-preserve/0.1"}
|
headers = {"User-Agent": "psppt/0.1"}
|
||||||
if self.token:
|
if self.token:
|
||||||
headers["Authorization"] = f"Bearer {self.token}"
|
headers["Authorization"] = f"Bearer {self.token}"
|
||||||
return headers
|
return headers
|
||||||
@@ -347,7 +347,7 @@ class ArchiveOrgSource(Source):
|
|||||||
return f"https://archive.org/download/{self.item}"
|
return f"https://archive.org/download/{self.item}"
|
||||||
|
|
||||||
def _headers(self):
|
def _headers(self):
|
||||||
headers = {"User-Agent": "psp-preserve/0.1"}
|
headers = {"User-Agent": "psppt/0.1"}
|
||||||
cookie = "; ".join(
|
cookie = "; ".join(
|
||||||
p for p in (
|
p for p in (
|
||||||
f"logged-in-sig={self.sig}" if self.sig else "",
|
f"logged-in-sig={self.sig}" if self.sig else "",
|
||||||
@@ -476,11 +476,10 @@ class NoPayStationSource(Source):
|
|||||||
|
|
||||||
label = "NoPayStation"
|
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
|
from . import nopaystation
|
||||||
self.content_type = content_type
|
self.content_type = content_type
|
||||||
self.tsv_url = tsv_url or nopaystation.tsv_url(content_type)
|
self.tsv_url = tsv_url or nopaystation.tsv_url(content_type)
|
||||||
self.pkg2zip = pkg2zip_path or ""
|
|
||||||
self.force = force
|
self.force = force
|
||||||
|
|
||||||
def scan(self, root=None):
|
def scan(self, root=None):
|
||||||
@@ -492,10 +491,7 @@ class NoPayStationSource(Source):
|
|||||||
dest_dir = Path(dest_dir)
|
dest_dir = Path(dest_dir)
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly;
|
# 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)
|
|
||||||
title_id = item.get("title_id") or item.get("content_id") or ""
|
title_id = item.get("title_id") or item.get("content_id") or ""
|
||||||
pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg"
|
pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg"
|
||||||
tmp = Path(tempfile.mkdtemp(prefix="nps-download-"))
|
tmp = Path(tempfile.mkdtemp(prefix="nps-download-"))
|
||||||
@@ -505,7 +501,7 @@ class NoPayStationSource(Source):
|
|||||||
progress_cb("Downloading", 0.0, "")
|
progress_cb("Downloading", 0.0, "")
|
||||||
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
||||||
kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None
|
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)
|
kind_hint, progress_cb, cancel_event)
|
||||||
if self.content_type in ("theme", "dlc"):
|
if self.content_type in ("theme", "dlc"):
|
||||||
_write_sidecar(folder, item)
|
_write_sidecar(folder, item)
|
||||||
|
|||||||
@@ -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.
Binary file not shown.
Binary file not shown.
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
psp-preserve:
|
psppt:
|
||||||
build: .
|
build: .
|
||||||
container_name: psp-preserve
|
container_name: psppt
|
||||||
# Attach to Unraid's macvlan bridge so the API gets its own LAN IP.
|
# 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.
|
# Plain HTTP for now; see Caddyfile to add Let's Encrypt TLS later.
|
||||||
networks:
|
networks:
|
||||||
br0:
|
br0:
|
||||||
ipv4_address: 192.168.10.100
|
ipv4_address: ${PSP_LAN_IP}
|
||||||
environment:
|
environment:
|
||||||
PSP_DATA_DIR: /data
|
PSP_DATA_DIR: /data
|
||||||
PSP_DATS_DIR: /data/dats
|
PSP_DATS_DIR: /data/dats
|
||||||
@@ -16,14 +16,14 @@ services:
|
|||||||
PSP_VIDEO_ROOTS: /data/umd_videos
|
PSP_VIDEO_ROOTS: /data/umd_videos
|
||||||
PSP_STORE_DIR: /data/store
|
PSP_STORE_DIR: /data/store
|
||||||
# Secret token the desktop app sends as `Authorization: Bearer <token>`.
|
# 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:-}
|
PSP_API_TOKEN: ${PSP_API_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
# Adjust host paths to your Unraid share layout:
|
# Host paths come from .env (see .env.example):
|
||||||
- /mnt/user/downloads/Sony - PlayStation Portable:/data/store:ro
|
- ${PSP_STORE_HOST}:/data/store:ro
|
||||||
- /mnt/user/psp/umd_games:/data/umd_games:ro
|
- ${PSP_UMD_GAMES_HOST}:/data/umd_games:ro
|
||||||
- /mnt/user/psp/umd_videos:/data/umd_videos:ro
|
- ${PSP_UMD_VIDEOS_HOST}:/data/umd_videos:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 273 B |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 689 KiB After Width: | Height: | Size: 683 KiB |
Executable
+81
@@ -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"
|
||||||
@@ -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
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
Reference in New Issue
Block a user