Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a552c61e | ||
|
|
bf1fa04f11 | ||
|
|
7b7d31bdfa | ||
|
|
14b0b955f5 | ||
|
|
b378214e9b | ||
|
|
0110b50a07 | ||
|
|
76eb6a6ef4 | ||
|
|
58d348859a | ||
|
|
3359ecf131 | ||
|
|
38a3859a21 | ||
|
|
96e876b8ec | ||
|
|
733e3c9089 | ||
|
|
dc7d6c9720 | ||
|
|
5c5bb6e5d6 | ||
|
|
35db22e3c5 | ||
|
|
b0cdac5483 | ||
|
|
f367bbe298 | ||
|
|
c61d8e9d3f |
+4
-2
@@ -5,8 +5,10 @@ PSP_LAN_IP=192.168.10.100
|
|||||||
|
|
||||||
# Host paths mounted into the container (Unraid shares).
|
# Host paths mounted into the container (Unraid shares).
|
||||||
PSP_STORE_HOST=/mnt/user/downloads/Sony - PlayStation Portable
|
PSP_STORE_HOST=/mnt/user/downloads/Sony - PlayStation Portable
|
||||||
|
PSP_STORE_VIDEOS_HOST=/mnt/user/psp/umd_videos_store
|
||||||
PSP_UMD_GAMES_HOST=/mnt/user/psp/umd_games
|
PSP_UMD_GAMES_HOST=/mnt/user/psp/umd_games
|
||||||
PSP_UMD_VIDEOS_HOST=/mnt/user/psp/umd_videos
|
PSP_UMD_VIDEOS_HOST=/mnt/user/psp/umd_videos
|
||||||
|
|
||||||
# Secret token the desktop app sends as `Authorization: Bearer <token>`.
|
# Initial admin account created on first boot (change the password after login).
|
||||||
PSP_API_TOKEN=
|
PSP_ADMIN_USERNAME=admin
|
||||||
|
PSP_ADMIN_PASSWORD=change-me-now
|
||||||
|
|||||||
@@ -22,15 +22,19 @@ jobs:
|
|||||||
pip install -r requirements-build.txt
|
pip install -r requirements-build.txt
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pyinstaller --clean --noconfirm psp-preserve.spec
|
run: pyinstaller --clean --noconfirm psppt.spec
|
||||||
|
|
||||||
- name: Package tarball
|
- name: Package tarball
|
||||||
run: tar -czf psp-preserve-linux-x86_64.tar.gz -C dist psp-preserve
|
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
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: psp-preserve-linux-x86_64
|
name: psppt-linux-x86_64
|
||||||
path: psp-preserve-linux-x86_64.tar.gz
|
path: psppt-linux-x86_64.tar.gz
|
||||||
|
|
||||||
windows:
|
windows:
|
||||||
name: Windows x64 (Wine)
|
name: Windows x64 (Wine)
|
||||||
@@ -44,13 +48,13 @@ jobs:
|
|||||||
winpython -m pip install -r requirements-build.txt
|
winpython -m pip install -r requirements-build.txt
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: winpython -m PyInstaller --clean --noconfirm psp-preserve.spec
|
run: winpython -m PyInstaller --clean --noconfirm psppt.spec
|
||||||
|
|
||||||
- name: Package zip
|
- name: Package zip
|
||||||
run: |
|
run: |
|
||||||
cd dist && zip -rq ../psp-preserve-windows-x64.zip psp-preserve
|
cd dist && zip -rq ../psppt-windows-x64.zip psppt
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: psp-preserve-windows-x64
|
name: psppt-windows-x64
|
||||||
path: psp-preserve-windows-x64.zip
|
path: psppt-windows-x64.zip
|
||||||
|
|||||||
@@ -24,3 +24,7 @@ data/nps/*.tsv
|
|||||||
# PyInstaller output
|
# PyInstaller output
|
||||||
build/
|
build/
|
||||||
dist/
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -2,8 +2,11 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# The API is stdlib-only (no pip install needed).
|
COPY requirements-server.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements-server.txt
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
|
COPY server ./server
|
||||||
|
|
||||||
ENV PSP_DATA_DIR=/data \
|
ENV PSP_DATA_DIR=/data \
|
||||||
PSP_DATS_DIR=/data/dats \
|
PSP_DATS_DIR=/data/dats \
|
||||||
@@ -11,8 +14,9 @@ ENV PSP_DATA_DIR=/data \
|
|||||||
PSP_COVERS_DIR=/data/covers \
|
PSP_COVERS_DIR=/data/covers \
|
||||||
PSP_GAMES_ROOTS=/data/umd_games \
|
PSP_GAMES_ROOTS=/data/umd_games \
|
||||||
PSP_VIDEO_ROOTS=/data/umd_videos \
|
PSP_VIDEO_ROOTS=/data/umd_videos \
|
||||||
PSP_STORE_DIR=/data/store
|
PSP_STORE_DIR=/data/store \
|
||||||
|
PSP_STORE_VIDEOS_DIR=/data/store_videos
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["python", "-m", "app.api", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# PSP Preservation Tool
|
# psppt
|
||||||
|
|
||||||
|
|
||||||
the name is terrible, i know.
|
the name is terrible, i know.
|
||||||
|
|
||||||
|

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

|

|
||||||
|
|
||||||
A cross-platform (Linux + Windows) desktop GUI — PySide6 — for preserving PSP
|
A cross-platform (Linux + Windows) desktop GUI — PySide6 — for preserving PSP
|
||||||
@@ -11,7 +15,7 @@ save-data with a real PSP, can pull missing discs from a Redump 7z archive
|
|||||||
collection, downloads PSN content from NoPayStation, and converts PlayStation 1
|
collection, downloads PSN content from NoPayStation, and converts PlayStation 1
|
||||||
ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
|
||||||
|
|
||||||

|

|
||||||
* Dark theme with a blue accent — the Library tab in cover view. *
|
* Dark theme with a blue accent — the Library tab in cover view. *
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -69,14 +73,16 @@ see the note inside).
|
|||||||
python3 -m venv .venv-build
|
python3 -m venv .venv-build
|
||||||
. .venv-build/bin/activate
|
. .venv-build/bin/activate
|
||||||
pip install -r requirements-build.txt
|
pip install -r requirements-build.txt
|
||||||
pyinstaller --clean --noconfirm psp-preserve.spec
|
pyinstaller --clean --noconfirm psppt.spec
|
||||||
# output: dist/psp-preserve/ → run dist/psp-preserve/psp-preserve
|
# output: dist/psppt/ → run dist/psppt/psppt
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The build bundles the DATs, PS1 loader, and the offline cover bundles. The
|
- The build bundles the DATs, PS1 loader, cover templates, and index catalogs
|
||||||
cover zips are large (~850 MB), so builds are slow and the output is big.
|
(~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
|
- `7z` (p7zip-full on Linux, 7-Zip on Windows) is a **runtime** dependency for
|
||||||
reading store archives — it is not bundled.
|
reading store archives — it is not bundled.
|
||||||
- User data (credentials, manual covers, PS1 templates, NPS cache, library DB,
|
- User data (credentials, manual covers, PS1 templates, NPS cache, library DB,
|
||||||
@@ -166,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
|
||||||
@@ -178,7 +185,7 @@ Discs are matched by hash first (most precise), then serial, then filename.
|
|||||||
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 in dev;
|
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev;
|
||||||
a packaged build stores them under the state dir instead).
|
a packaged build stores them under the state dir instead).
|
||||||
- State dir (`~/.local/state/psp-preserve/` on Linux, `%LOCALAPPDATA%\PSPPreserve`
|
- State dir (`~/.local/state/psppt/` on Linux, `%LOCALAPPDATA%\psppt`
|
||||||
on Windows, or `$PSP_STATE_DIR`):
|
on Windows, or `$PSP_STATE_DIR`):
|
||||||
- `library.sqlite` — the catalog + library database.
|
- `library.sqlite` — the catalog + library database.
|
||||||
- `covers/` — downloaded cover cache.
|
- `covers/` — downloaded cover cache.
|
||||||
@@ -192,13 +199,13 @@ 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 |
|
||||||
@@ -219,6 +226,7 @@ QSettings under `PSPPreserve`) taking precedence where noted.
|
|||||||
| `PSP_CREDENTIALS` | `$PSP_USER_DATA_DIR/credentials.json` | credentials file |
|
| `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`,
|
||||||
@@ -251,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
|
||||||
@@ -385,52 +404,68 @@ Requirements & behaviour:
|
|||||||
|
|
||||||
## Server setup
|
## Server setup
|
||||||
|
|
||||||
The catalog + store API is a single-file, stdlib-only HTTP server
|
The server is a **FastAPI** app (`server/`) with an admin **WebUI** and a
|
||||||
(`app/api.py`) — no pip install needed on the server.
|
JSON API. It owns the catalog, metadata, covers, and the game/video store
|
||||||
|
archives, and serves them to the desktop app. Users log in with a username +
|
||||||
|
password; the app holds a **per-user API token** (roles: `admin`, `editor`,
|
||||||
|
`user`).
|
||||||
|
|
||||||
|
Run it directly (dev):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PSP_DATA_DIR=/data \
|
pip install -r requirements-server.txt
|
||||||
PSP_DATS_DIR=/data/dats \
|
|
||||||
PSP_DB_PATH=/data/library.sqlite \
|
PSP_DB_PATH=/data/library.sqlite \
|
||||||
PSP_COVERS_DIR=/data/covers \
|
PSP_COVERS_DIR=/data/covers \
|
||||||
PSP_GAMES_ROOTS=/data/umd_games \
|
|
||||||
PSP_VIDEO_ROOTS=/data/umd_videos \
|
|
||||||
PSP_STORE_DIR=/data/store \
|
PSP_STORE_DIR=/data/store \
|
||||||
PSP_API_TOKEN=<long-random-secret> \
|
PSP_STORE_VIDEOS_DIR=/data/store_videos \
|
||||||
python3 -m app.api --host 0.0.0.0 --port 8000
|
PSP_ADMIN_USERNAME=admin \
|
||||||
|
PSP_ADMIN_PASSWORD=<change-me> \
|
||||||
|
uvicorn server.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoints:
|
On first boot it creates the `PSP_ADMIN_USERNAME` account (log in and change
|
||||||
|
the password). The admin WebUI lives at `/`:
|
||||||
|
|
||||||
|
- **Dashboard** — storage, catalog counts, recent downloads.
|
||||||
|
- **Games & Videos** — browse the catalog, edit metadata, upload covers.
|
||||||
|
- **Store** — upload/delete game and video archives.
|
||||||
|
- **Users** — create users, assign roles, reset passwords.
|
||||||
|
|
||||||
|
API endpoints:
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/summary` | release counts per system |
|
| `POST /api/auth/login` | username/password → API token |
|
||||||
|
| `GET /api/auth/me` | current user |
|
||||||
|
| `GET /api/users`, `POST`, `PATCH`, `DELETE` | user management *(admin)* |
|
||||||
| `GET /api/releases?system=&q=&limit=&offset=` | search the catalog |
|
| `GET /api/releases?system=&q=&limit=&offset=` | search the catalog |
|
||||||
| `GET /api/releases/<id>` | one release + its rom hashes |
|
| `GET /api/releases/<id>` | release detail + metadata + rom hashes |
|
||||||
| `GET /api/library?system=&refresh=` | your scanned library |
|
| `PATCH /api/releases/<id>` | edit metadata *(editor+)* |
|
||||||
| `GET /api/cover/<id>` | cover image (fetched on demand) |
|
| `GET /api/cover/<id>` | cover image |
|
||||||
| `GET /api/covers/<name>.zip` | offline cover bundle (public) |
|
| `POST /api/cover/<id>` | upload/replace cover *(editor+)* |
|
||||||
| `GET /api/store` | list store archives *(token)* |
|
| `GET /api/store?system=games|videos` | list store archives |
|
||||||
| `GET /api/store/download/<name>` | stream one archive *(token)* |
|
| `GET /api/store/download/<system>/<name>` | stream an archive *(user+)* |
|
||||||
|
| `POST /api/store/upload`, `DELETE /api/store/<system>/<name>` | manage archives *(editor+)* |
|
||||||
|
| `GET /api/stats` | dashboard numbers *(admin)* |
|
||||||
|
|
||||||
Token protection: set `PSP_API_TOKEN`; the client sends
|
Authentication is `Authorization: Bearer <token>` for the API and a session
|
||||||
`Authorization: Bearer <token>`. Leave it unset to allow anonymous access
|
cookie for the WebUI.
|
||||||
(not recommended).
|
|
||||||
|
|
||||||
### Docker (Unraid)
|
### Docker (Unraid)
|
||||||
|
|
||||||
`docker-compose.yml` builds the API image and attaches it to Unraid's `br0`
|
`docker-compose.yml` builds the server image and attaches it to Unraid's `br0`
|
||||||
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# in /path/to/psp-preserve/
|
# in /path/to/psppt/
|
||||||
cp .env.example .env # then edit: LAN IP, share paths, API token
|
cp .env.example .env # then edit: LAN IP, share paths, admin password
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The compose file reads the LAN IP and Unraid share paths from `.env` and
|
The compose file reads the LAN IP and Unraid share paths from `.env` and mounts
|
||||||
mounts them into `/data/store`, `/data/umd_games` and `/data/umd_videos`
|
them into `/data/store`, `/data/store_videos`, `/data/umd_games` and
|
||||||
(the store is mounted read-only).
|
`/data/umd_videos` (the store mounts are writable so the WebUI can manage
|
||||||
|
archives).
|
||||||
|
|
||||||
### TLS (optional)
|
### TLS (optional)
|
||||||
|
|
||||||
@@ -441,10 +476,13 @@ ports 80/443 on your router to it.
|
|||||||
|
|
||||||
### Client
|
### Client
|
||||||
|
|
||||||
In the GUI: **Settings → Store** → enter the **Server URL** (e.g.
|
In the GUI: **Settings → Store** → enter the **Server URL**, **Server
|
||||||
`http://<server-ip>:8000` or `https://psp.example.com`) and the same **API
|
username**, and **Server password**, then **Save settings** (the app logs in
|
||||||
token**, then pick **Server** in the Store tab's source selector. Use
|
and stores the per-user token). Pick **Server** in the Store tab's source
|
||||||
**Archive.org** or **Local folder** the same way.
|
selector. When a server is configured it is authoritative for covers and
|
||||||
|
metadata — if it's unreachable the app shows an error instead of falling back
|
||||||
|
to public sources. Use **Archive.org**, **Local folder**, or **NoPayStation**
|
||||||
|
the same way as before.
|
||||||
|
|
||||||
## PSP Sync
|
## PSP Sync
|
||||||
|
|
||||||
|
|||||||
+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=",
|
||||||
|
|||||||
+14
-2
@@ -42,10 +42,10 @@ def _default_state_dir():
|
|||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or str(Path.home())
|
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or str(Path.home())
|
||||||
return Path(base) / "PSPPreserve"
|
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())))
|
||||||
@@ -76,9 +76,21 @@ 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")
|
||||||
|
|
||||||
|
# UMD video store: a second archive root (mirrors STORE_ROOT) and the
|
||||||
|
# archive.org item hosting UMD video rips.
|
||||||
|
STORE_VIDEOS_ROOT = Path(os.environ.get("PSP_STORE_VIDEOS_DIR", "/data/store_videos"))
|
||||||
|
ARCHIVE_VIDEOS_ITEM = os.environ.get("PSP_ARCHIVE_VIDEOS_ITEM", "the-psp-movie-collection-so-far_202607")
|
||||||
|
|
||||||
# Optional archive.org login cookies (for items whose files are "private" /
|
# Optional archive.org login cookies (for items whose files are "private" /
|
||||||
# login-required). Grab these from a logged-in browser session on archive.org.
|
# login-required). Grab these from a logged-in browser session on archive.org.
|
||||||
ARCHIVE_LOGGED_IN_SIG = os.environ.get("PSP_ARCHIVE_LOGGED_IN_SIG", "")
|
ARCHIVE_LOGGED_IN_SIG = os.environ.get("PSP_ARCHIVE_LOGGED_IN_SIG", "")
|
||||||
|
|||||||
+90
-35
@@ -19,7 +19,7 @@ import urllib.request
|
|||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import config, sources
|
from . import config, server_client, sources
|
||||||
from .db import get_connection
|
from .db import get_connection
|
||||||
|
|
||||||
log = logging.getLogger("covers")
|
log = logging.getLogger("covers")
|
||||||
@@ -53,8 +53,20 @@ _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.USER_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 = []
|
|
||||||
for name, path, direct in (
|
|
||||||
("libretro-covers.zip", _LIBRETRO_ZIP, config.LIBRETRO_COVERS_URL),
|
|
||||||
("openvgdb-covers.zip", _OVGDB_ZIP, config.OVGDB_COVERS_URL),
|
|
||||||
):
|
|
||||||
if path.exists():
|
|
||||||
continue
|
|
||||||
url = direct or (f"{base}/api/covers/{name}" if base else "")
|
|
||||||
if not url:
|
if not url:
|
||||||
continue
|
raise ValueError("no cover bundle URL configured")
|
||||||
try:
|
config.COVERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
if _download_file(url, path):
|
_download_file(url, _BUNDLE_ZIP, timeout=3600,
|
||||||
downloaded.append(name)
|
progress_cb=progress_cb, cancel_event=cancel_event)
|
||||||
log.info("downloaded offline cover bundle %s", name)
|
inner = ("libretro-covers.zip", "openvgdb-covers.zip")
|
||||||
except Exception as exc: # noqa: BLE001
|
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
|
||||||
log.warning("could not download cover bundle %s: %s", name, exc)
|
missing = [n for n in inner if n not in z.namelist()]
|
||||||
return downloaded
|
if missing:
|
||||||
|
raise ValueError(f"cover bundle is missing: {', '.join(missing)}")
|
||||||
|
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
|
||||||
|
for name in inner:
|
||||||
|
out = config.COVERS_DIR / name
|
||||||
|
tmp = out.with_name(out.name + ".tmp")
|
||||||
|
with z.open(name) as src, open(tmp, "wb") as dst:
|
||||||
|
while True:
|
||||||
|
chunk = src.read(1 << 20)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
dst.write(chunk)
|
||||||
|
os.replace(tmp, out)
|
||||||
|
# Invalidate cached namelists so lookups pick up the freshly extracted zips.
|
||||||
|
global _libretro_names, _ovgdb_names
|
||||||
|
_libretro_names = None
|
||||||
|
_ovgdb_names = None
|
||||||
|
return config.COVERS_DIR
|
||||||
|
|
||||||
|
|
||||||
def cover_url(name, kind="Named_Boxarts", system="games"):
|
def cover_url(name, kind="Named_Boxarts", system="games"):
|
||||||
@@ -482,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
|
||||||
@@ -535,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
|
||||||
@@ -559,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
|
||||||
@@ -570,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
|
||||||
@@ -610,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",
|
||||||
@@ -1180,6 +1220,21 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Server is authoritative for game/video covers when configured — no
|
||||||
|
# libretro/IGDB fallback; if the server is unreachable the cover just
|
||||||
|
# stays empty. PS1/theme art stays client-side (the server catalog only
|
||||||
|
# covers games and videos).
|
||||||
|
if server_client.configured() and system in ("games", "videos"):
|
||||||
|
try:
|
||||||
|
data = server_client.fetch_cover(system, name)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("server cover fetch failed for %r: %s", name, exc)
|
||||||
|
return None
|
||||||
|
if data is not None and _is_image(data):
|
||||||
|
_write_atomic(dest, data)
|
||||||
|
return dest
|
||||||
|
return None
|
||||||
|
|
||||||
key = str(release_id)
|
key = str(release_id)
|
||||||
# In-memory + persistent negative caches: skip the network for recent misses.
|
# In-memory + persistent negative caches: skip the network for recent misses.
|
||||||
last = _neg_cache.get(key)
|
last = _neg_cache.get(key)
|
||||||
|
|||||||
@@ -115,6 +115,37 @@ CREATE TABLE IF NOT EXISTS ps1_entries (
|
|||||||
error TEXT,
|
error TEXT,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user', -- admin / editor / user
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'api', -- api / session
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS download_events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
system TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+228
-52
@@ -1,4 +1,4 @@
|
|||||||
"""Native Linux GUI for the PSP Preservation Tool (PySide6 / Qt).
|
"""Native Linux GUI for psppt (PySide6 / Qt).
|
||||||
|
|
||||||
Run with::
|
Run with::
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ Run with::
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -21,7 +22,7 @@ from PySide6.QtWidgets import (
|
|||||||
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QGroupBox,
|
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QGroupBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import config, covers, info, ingest, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, style, theme, transfers
|
from . import config, covers, info, ingest, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, server_client, sources, store, style, theme, transfers
|
||||||
from .db import get_connection, init_db
|
from .db import get_connection, init_db
|
||||||
|
|
||||||
log = logging.getLogger("gui")
|
log = logging.getLogger("gui")
|
||||||
@@ -117,14 +118,16 @@ def _thread_running(thread):
|
|||||||
|
|
||||||
|
|
||||||
class CoverLoader(QObject):
|
class CoverLoader(QObject):
|
||||||
loaded = Signal(int, int, str) # gen, item index, cached image path
|
loaded = Signal(int, int, QImage) # gen, item index, fitted cover image
|
||||||
failed = Signal(int, int, str) # gen, item index, name (no cover found)
|
failed = Signal(int, int, str) # gen, item index, name (no cover found)
|
||||||
finished = Signal()
|
finished = Signal()
|
||||||
|
|
||||||
def __init__(self, items, gen):
|
def __init__(self, items, gen, icon_w=150, icon_h=220):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.items = items
|
self.items = items
|
||||||
self.gen = gen
|
self.gen = gen
|
||||||
|
self.icon_w = icon_w
|
||||||
|
self.icon_h = icon_h
|
||||||
self._cancel = threading.Event()
|
self._cancel = threading.Event()
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
@@ -148,11 +151,17 @@ class CoverLoader(QObject):
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
path = None
|
path = None
|
||||||
if path is not None:
|
if path is not None:
|
||||||
self.loaded.emit(self.gen, i, str(path))
|
img = QImage(str(path))
|
||||||
|
if img.isNull():
|
||||||
|
self.failed.emit(self.gen, i, self.items[i]["name"])
|
||||||
|
else:
|
||||||
|
self.loaded.emit(self.gen, i, _fit_image(img, self.icon_w, self.icon_h))
|
||||||
else:
|
else:
|
||||||
self.failed.emit(self.gen, i, self.items[i]["name"])
|
self.failed.emit(self.gen, i, self.items[i]["name"])
|
||||||
finally:
|
finally:
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
# Return promptly on cancel/exit instead of blocking the GUI thread
|
||||||
|
# on in-flight network timeouts during shutdown.
|
||||||
|
pool.shutdown(wait=not self._cancel.is_set(), cancel_futures=True)
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
@@ -276,6 +285,25 @@ def _fit_pixmap(pm, w, h):
|
|||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_image(img, w, h):
|
||||||
|
"""Scale a QImage into a letterboxed ``w x h`` ARGB image (thread-safe).
|
||||||
|
|
||||||
|
Decoding + scaling happen off the GUI thread in the cover loaders; the
|
||||||
|
main thread only converts the result to a QPixmap (cheap).
|
||||||
|
"""
|
||||||
|
canvas = QImage(w, h, QImage.Format_ARGB32)
|
||||||
|
canvas.fill(Qt.transparent)
|
||||||
|
if img.isNull():
|
||||||
|
return canvas
|
||||||
|
scaled = img.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||||
|
x = (w - scaled.width()) // 2
|
||||||
|
y = (h - scaled.height()) // 2
|
||||||
|
painter = QPainter(canvas)
|
||||||
|
painter.drawImage(x, y, scaled)
|
||||||
|
painter.end()
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
def _cover_pixmap(rid, system, max_w, max_h):
|
def _cover_pixmap(rid, system, max_w, max_h):
|
||||||
"""Return a scaled cover pixmap for an item, or None if unavailable."""
|
"""Return a scaled cover pixmap for an item, or None if unavailable."""
|
||||||
if rid is None:
|
if rid is None:
|
||||||
@@ -429,7 +457,7 @@ class CoverShowcase(QWidget):
|
|||||||
for loader in self._loaders:
|
for loader in self._loaders:
|
||||||
loader.cancel()
|
loader.cancel()
|
||||||
self.thread = QThread(self)
|
self.thread = QThread(self)
|
||||||
self.loader = CoverLoader(self.items, gen)
|
self.loader = CoverLoader(self.items, gen, self.ICON_W, self.ICON_H)
|
||||||
self.loader.moveToThread(self.thread)
|
self.loader.moveToThread(self.thread)
|
||||||
self.thread.started.connect(self.loader.run)
|
self.thread.started.connect(self.loader.run)
|
||||||
self.loader.loaded.connect(self._on_loaded)
|
self.loader.loaded.connect(self._on_loaded)
|
||||||
@@ -454,12 +482,12 @@ class CoverShowcase(QWidget):
|
|||||||
painter.end()
|
painter.end()
|
||||||
return QIcon(pm)
|
return QIcon(pm)
|
||||||
|
|
||||||
def _on_loaded(self, gen, index, path):
|
def _on_loaded(self, gen, index, image):
|
||||||
if gen != self._generation:
|
if self._closing or gen != self._generation:
|
||||||
return
|
return
|
||||||
item = self.list.item(index)
|
item = self.list.item(index)
|
||||||
if item is not None:
|
if item is not None:
|
||||||
item.setIcon(self._fit_icon(QPixmap(path)))
|
item.setIcon(QIcon(QPixmap.fromImage(image)))
|
||||||
if 0 <= index < len(self.items):
|
if 0 <= index < len(self.items):
|
||||||
self.cover_loaded.emit(self.items[index])
|
self.cover_loaded.emit(self.items[index])
|
||||||
|
|
||||||
@@ -477,7 +505,7 @@ class CoverShowcase(QWidget):
|
|||||||
self.play_requested.emit(data)
|
self.play_requested.emit(data)
|
||||||
|
|
||||||
def _on_failed(self, gen, index, name):
|
def _on_failed(self, gen, index, name):
|
||||||
if gen != self._generation:
|
if self._closing or gen != self._generation:
|
||||||
return
|
return
|
||||||
if index not in self.missing:
|
if index not in self.missing:
|
||||||
self.missing.append(index)
|
self.missing.append(index)
|
||||||
@@ -485,6 +513,8 @@ class CoverShowcase(QWidget):
|
|||||||
self._update_status()
|
self._update_status()
|
||||||
|
|
||||||
def _on_loading_done(self):
|
def _on_loading_done(self):
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
sender = self.sender()
|
sender = self.sender()
|
||||||
if sender is not None and getattr(sender, "gen", None) != self._generation:
|
if sender is not None and getattr(sender, "gen", None) != self._generation:
|
||||||
return
|
return
|
||||||
@@ -492,6 +522,8 @@ class CoverShowcase(QWidget):
|
|||||||
self._update_status()
|
self._update_status()
|
||||||
|
|
||||||
def _update_status(self):
|
def _update_status(self):
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
if self._loading:
|
if self._loading:
|
||||||
self.status.setText("Loading covers…")
|
self.status.setText("Loading covers…")
|
||||||
self.missing_changed.emit(0)
|
self.missing_changed.emit(0)
|
||||||
@@ -1111,13 +1143,14 @@ class ScrapeDialog(QDialog):
|
|||||||
|
|
||||||
class SettingsPage(QWidget):
|
class SettingsPage(QWidget):
|
||||||
saved = Signal()
|
saved = Signal()
|
||||||
|
download_covers = Signal()
|
||||||
|
|
||||||
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
|
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
|
||||||
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
|
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
|
||||||
iso_category, ps1_category, psp_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
|
iso_category, ps1_category, psp_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
|
||||||
nps_dir="", parent=None):
|
nps_dir="", parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
s = QSettings("PSPPreserve", "PSPPreserve")
|
s = QSettings("psppt", "psppt")
|
||||||
|
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(16, 16, 16, 16)
|
outer.setContentsMargins(16, 16, 16, 16)
|
||||||
@@ -1167,7 +1200,8 @@ class SettingsPage(QWidget):
|
|||||||
# -- Store ----------------------------------------------------------- #
|
# -- Store ----------------------------------------------------------- #
|
||||||
box, form = self._group("Store")
|
box, form = self._group("Store")
|
||||||
self.store_server_edit = self._line_row(form, "Server URL", store_server_url)
|
self.store_server_edit = self._line_row(form, "Server URL", store_server_url)
|
||||||
self.store_token_edit = self._secret_row(form, "API token", store_token)
|
self.store_user_edit = self._line_row(form, "Server username", server_client.username())
|
||||||
|
self.store_pass_edit = self._secret_row(form, "Server password", "")
|
||||||
self.archive_edit = self._line_row(form, "Archive.org item", store_archive_id)
|
self.archive_edit = self._line_row(form, "Archive.org item", store_archive_id)
|
||||||
self.archive_sig_edit = self._secret_row(form, "Archive.org logged-in-sig", store_archive_sig)
|
self.archive_sig_edit = self._secret_row(form, "Archive.org logged-in-sig", store_archive_sig)
|
||||||
self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user", store_archive_user)
|
self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user", store_archive_user)
|
||||||
@@ -1278,6 +1312,9 @@ class SettingsPage(QWidget):
|
|||||||
self.clear_covers_btn = QPushButton("Clear cover cache (re-fetch all covers)")
|
self.clear_covers_btn = QPushButton("Clear cover cache (re-fetch all covers)")
|
||||||
self.clear_covers_btn.clicked.connect(self._clear_cover_cache)
|
self.clear_covers_btn.clicked.connect(self._clear_cover_cache)
|
||||||
form.addRow(self.clear_covers_btn)
|
form.addRow(self.clear_covers_btn)
|
||||||
|
self.download_bundle_btn = QPushButton("Download offline cover bundle…")
|
||||||
|
self.download_bundle_btn.clicked.connect(self.download_covers.emit)
|
||||||
|
form.addRow(self.download_bundle_btn)
|
||||||
col.addWidget(box)
|
col.addWidget(box)
|
||||||
|
|
||||||
# -- Storage ---------------------------------------------------------- #
|
# -- Storage ---------------------------------------------------------- #
|
||||||
@@ -1289,7 +1326,7 @@ class SettingsPage(QWidget):
|
|||||||
note = QLabel(
|
note = QLabel(
|
||||||
"Your library, covers, and credentials live here — outside the app "
|
"Your library, covers, and credentials live here — outside the app "
|
||||||
"bundle, so they survive upgrades. On Linux this is the "
|
"bundle, so they survive upgrades. On Linux this is the "
|
||||||
"~/.local/state/psp-preserve folder."
|
"~/.local/state/psppt folder."
|
||||||
)
|
)
|
||||||
note.setWordWrap(True)
|
note.setWordWrap(True)
|
||||||
note.setStyleSheet("color: #9a9ba5;")
|
note.setStyleSheet("color: #9a9ba5;")
|
||||||
@@ -1394,7 +1431,7 @@ class SettingsPage(QWidget):
|
|||||||
return self.store_server_edit.text().strip()
|
return self.store_server_edit.text().strip()
|
||||||
|
|
||||||
def store_token(self):
|
def store_token(self):
|
||||||
return self.store_token_edit.text().strip()
|
return server_client.token()
|
||||||
|
|
||||||
def archive_item(self):
|
def archive_item(self):
|
||||||
return self.archive_edit.text().strip()
|
return self.archive_edit.text().strip()
|
||||||
@@ -1444,7 +1481,19 @@ class SettingsPage(QWidget):
|
|||||||
def _save(self):
|
def _save(self):
|
||||||
sources.save({key: edit.text() for key, edit in self.fields.items()})
|
sources.save({key: edit.text() for key, edit in self.fields.items()})
|
||||||
covers.clear_miss_cache()
|
covers.clear_miss_cache()
|
||||||
s = QSettings("PSPPreserve", "PSPPreserve")
|
# Log in to the content server (stores a per-user API token).
|
||||||
|
url = self.store_server_edit.text().strip()
|
||||||
|
user = self.store_user_edit.text().strip()
|
||||||
|
pw = self.store_pass_edit.text()
|
||||||
|
if url and user and pw:
|
||||||
|
try:
|
||||||
|
server_client.login(url, user, pw)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
QMessageBox.warning(self, "Server login failed",
|
||||||
|
f"Could not log in to the server:\n{exc}")
|
||||||
|
elif url and not user:
|
||||||
|
server_client.logout()
|
||||||
|
s = QSettings("psppt", "psppt")
|
||||||
s.setValue("auto_scan", self.auto_scan.isChecked())
|
s.setValue("auto_scan", self.auto_scan.isChecked())
|
||||||
s.setValue("scan_workers", self.workers_spin.value())
|
s.setValue("scan_workers", self.workers_spin.value())
|
||||||
s.setValue("auto_sync_saves", self.auto_sync.isChecked())
|
s.setValue("auto_sync_saves", self.auto_sync.isChecked())
|
||||||
@@ -1479,14 +1528,16 @@ class StoreScanWorker(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class StoreCoverLoader(QObject):
|
class StoreCoverLoader(QObject):
|
||||||
loaded = Signal(int, int, str) # gen, grid row, cached image path
|
loaded = Signal(int, int, QImage) # gen, grid row, fitted cover image
|
||||||
finished = Signal()
|
finished = Signal()
|
||||||
|
|
||||||
def __init__(self, tasks, gen, quick=True):
|
def __init__(self, tasks, gen, quick=True, icon_w=150, icon_h=220):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.tasks = tasks # list of (grid_row, item_dict)
|
self.tasks = tasks # list of (grid_row, item_dict)
|
||||||
self.gen = gen
|
self.gen = gen
|
||||||
self.quick = quick
|
self.quick = quick
|
||||||
|
self.icon_w = icon_w
|
||||||
|
self.icon_h = icon_h
|
||||||
self._cancel = threading.Event()
|
self._cancel = threading.Event()
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
@@ -1510,9 +1561,13 @@ class StoreCoverLoader(QObject):
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
path = None
|
path = None
|
||||||
if path is not None:
|
if path is not None:
|
||||||
self.loaded.emit(self.gen, row, str(path))
|
img = QImage(str(path))
|
||||||
|
if not img.isNull():
|
||||||
|
self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h))
|
||||||
finally:
|
finally:
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
# Return promptly on cancel/exit instead of blocking the GUI thread
|
||||||
|
# on in-flight network timeouts during shutdown.
|
||||||
|
pool.shutdown(wait=not self._cancel.is_set(), cancel_futures=True)
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
@@ -1789,6 +1844,8 @@ class StorePage(QWidget):
|
|||||||
self._lib_cover = {} # norm -> (release_id, system)
|
self._lib_cover = {} # norm -> (release_id, system)
|
||||||
self._psp_norm = set() # norm of library items installed on the PSP
|
self._psp_norm = set() # norm of library items installed on the PSP
|
||||||
self._cover_attempted = set()
|
self._cover_attempted = set()
|
||||||
|
self._cover_busy = False
|
||||||
|
self._date_busy = False
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
self._scan_gen = 0
|
self._scan_gen = 0
|
||||||
self._meta_gen = 0
|
self._meta_gen = 0
|
||||||
@@ -1816,7 +1873,7 @@ class StorePage(QWidget):
|
|||||||
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
||||||
bar.addWidget(self.source_combo)
|
bar.addWidget(self.source_combo)
|
||||||
self.nps_type = QComboBox()
|
self.nps_type = QComboBox()
|
||||||
self.nps_type.addItems(["PSP Games", "PS1 Classics", "PSP Themes", "PSP DLC"])
|
self.nps_type.addItems(["All", "PSP Games", "PS1 Classics", "PSP Themes", "PSP DLC"])
|
||||||
self.nps_type.setVisible(False)
|
self.nps_type.setVisible(False)
|
||||||
self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed)
|
self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed)
|
||||||
bar.addWidget(self.nps_type)
|
bar.addWidget(self.nps_type)
|
||||||
@@ -1928,7 +1985,7 @@ class StorePage(QWidget):
|
|||||||
if self.source_combo.currentIndex() == 1:
|
if self.source_combo.currentIndex() == 1:
|
||||||
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)
|
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)
|
||||||
if self.source_combo.currentIndex() == 3:
|
if self.source_combo.currentIndex() == 3:
|
||||||
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
|
ct = ("all", "psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
|
||||||
return transfers.NoPayStationSource(ct)
|
return transfers.NoPayStationSource(ct)
|
||||||
return transfers.LocalShareSource(self._store_dir)
|
return transfers.LocalShareSource(self._store_dir)
|
||||||
|
|
||||||
@@ -1975,9 +2032,27 @@ class StorePage(QWidget):
|
|||||||
if self._items:
|
if self._items:
|
||||||
self._apply_filter()
|
self._apply_filter()
|
||||||
|
|
||||||
|
def _begin_scan(self):
|
||||||
|
"""Clear the stale view and invalidate in-flight cover/date batches."""
|
||||||
|
self._generation += 1
|
||||||
|
self._cover_busy = False
|
||||||
|
self._date_busy = False
|
||||||
|
self._cover_queue = []
|
||||||
|
self._cover_next = 0
|
||||||
|
self._date_queue = []
|
||||||
|
self._date_next = 0
|
||||||
|
self._cover_attempted.clear()
|
||||||
|
for loader in self._loaders:
|
||||||
|
loader.cancel()
|
||||||
|
self._loaders = []
|
||||||
|
self.grid.clear()
|
||||||
|
self.table.setRowCount(0)
|
||||||
|
self.status.setText("Loading…")
|
||||||
|
|
||||||
def refresh(self, force=False):
|
def refresh(self, force=False):
|
||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
|
self._begin_scan()
|
||||||
self._scan_gen += 1
|
self._scan_gen += 1
|
||||||
gen = self._scan_gen
|
gen = self._scan_gen
|
||||||
if self.source_combo.currentIndex() == 0: # Server
|
if self.source_combo.currentIndex() == 0: # Server
|
||||||
@@ -1995,7 +2070,7 @@ class StorePage(QWidget):
|
|||||||
self.status.setText("Fetching store from archive.org…")
|
self.status.setText("Fetching store from archive.org…")
|
||||||
worker = RemoteStoreScanWorker(transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user))
|
worker = RemoteStoreScanWorker(transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user))
|
||||||
elif self.source_combo.currentIndex() == 3: # NoPayStation
|
elif self.source_combo.currentIndex() == 3: # NoPayStation
|
||||||
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
|
ct = ("all", "psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
|
||||||
log.info("store: fetching NoPayStation %s list", ct)
|
log.info("store: fetching NoPayStation %s list", ct)
|
||||||
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…")
|
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…")
|
||||||
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, force=force))
|
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, force=force))
|
||||||
@@ -2038,6 +2113,8 @@ class StorePage(QWidget):
|
|||||||
self._items = []
|
self._items = []
|
||||||
self._filtered = []
|
self._filtered = []
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
|
self.grid.clear()
|
||||||
|
self.table.setRowCount(0)
|
||||||
log.warning("store unavailable: %s", message)
|
log.warning("store unavailable: %s", message)
|
||||||
self.status.setText(f"Store unavailable: {message}")
|
self.status.setText(f"Store unavailable: {message}")
|
||||||
|
|
||||||
@@ -2138,6 +2215,8 @@ class StorePage(QWidget):
|
|||||||
self._cover_next = 0
|
self._cover_next = 0
|
||||||
self._date_queue = []
|
self._date_queue = []
|
||||||
self._date_next = 0
|
self._date_next = 0
|
||||||
|
self._cover_busy = False
|
||||||
|
self._date_busy = False
|
||||||
self._cover_attempted.clear()
|
self._cover_attempted.clear()
|
||||||
self._placeholder = QIcon(_placeholder_pixmap(self.ICON_W, self.ICON_H))
|
self._placeholder = QIcon(_placeholder_pixmap(self.ICON_W, self.ICON_H))
|
||||||
|
|
||||||
@@ -2147,6 +2226,7 @@ class StorePage(QWidget):
|
|||||||
self._loaders = []
|
self._loaders = []
|
||||||
|
|
||||||
self.grid.clear()
|
self.grid.clear()
|
||||||
|
self.grid.setUpdatesEnabled(False)
|
||||||
self.table.setUpdatesEnabled(False)
|
self.table.setUpdatesEnabled(False)
|
||||||
self.table.setSortingEnabled(False)
|
self.table.setSortingEnabled(False)
|
||||||
self.table.setRowCount(show_count)
|
self.table.setRowCount(show_count)
|
||||||
@@ -2189,10 +2269,10 @@ class StorePage(QWidget):
|
|||||||
rid, system = lc
|
rid, system = lc
|
||||||
else:
|
else:
|
||||||
rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
|
rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
|
||||||
p = covers.cover_path(rid, system)
|
# Covers are decoded + scaled off the GUI thread (StoreCoverLoader).
|
||||||
if p.exists() and covers.file_is_image(p):
|
# Never load a QPixmap here — doing so for cached covers made the store
|
||||||
item.setIcon(QIcon(_fit_pixmap(QPixmap(str(p)), self.ICON_W, self.ICON_H)))
|
# unresponsive while filling/scrolling.
|
||||||
elif system not in ("theme", "dlc") and rid not in self._cover_attempted:
|
if system not in ("theme", "dlc") and rid not in self._cover_attempted:
|
||||||
# Themes and DLC have no meaningful box art in libretro/IGDB, and
|
# Themes and DLC have no meaningful box art in libretro/IGDB, and
|
||||||
# bulk-scraping hundreds of them on every switch to the DLC list
|
# bulk-scraping hundreds of them on every switch to the DLC list
|
||||||
# saturates the network/CPU and makes the store unresponsive.
|
# saturates the network/CPU and makes the store unresponsive.
|
||||||
@@ -2251,6 +2331,7 @@ class StorePage(QWidget):
|
|||||||
def _finish_fill(self):
|
def _finish_fill(self):
|
||||||
if self._fill_timer is not None:
|
if self._fill_timer is not None:
|
||||||
self._fill_timer.stop()
|
self._fill_timer.stop()
|
||||||
|
self.grid.setUpdatesEnabled(True)
|
||||||
self.table.setUpdatesEnabled(True)
|
self.table.setUpdatesEnabled(True)
|
||||||
self.table.setSortingEnabled(True)
|
self.table.setSortingEnabled(True)
|
||||||
total = len(self._filtered)
|
total = len(self._filtered)
|
||||||
@@ -2263,13 +2344,14 @@ class StorePage(QWidget):
|
|||||||
self._load_date_batch(self.DATE_BATCH)
|
self._load_date_batch(self.DATE_BATCH)
|
||||||
|
|
||||||
def _load_cover_batch(self, n):
|
def _load_cover_batch(self, n):
|
||||||
if self._closing or not self._cover_queue:
|
if self._closing or not self._cover_queue or self._cover_busy:
|
||||||
return
|
return
|
||||||
end = min(self._cover_next + n, len(self._cover_queue))
|
end = min(self._cover_next + n, len(self._cover_queue))
|
||||||
if self._cover_next >= end:
|
if self._cover_next >= end:
|
||||||
return
|
return
|
||||||
tasks = self._cover_queue[self._cover_next:end]
|
tasks = self._cover_queue[self._cover_next:end]
|
||||||
self._cover_next = end
|
self._cover_next = end
|
||||||
|
self._cover_busy = True
|
||||||
self._start_cover_loader(self._generation, tasks)
|
self._start_cover_loader(self._generation, tasks)
|
||||||
|
|
||||||
def _active_scrollbar(self):
|
def _active_scrollbar(self):
|
||||||
@@ -2300,13 +2382,14 @@ class StorePage(QWidget):
|
|||||||
self._fill_timer.start()
|
self._fill_timer.start()
|
||||||
|
|
||||||
def _load_date_batch(self, n):
|
def _load_date_batch(self, n):
|
||||||
if self._closing or not self._date_queue:
|
if self._closing or not self._date_queue or self._date_busy:
|
||||||
return
|
return
|
||||||
end = min(self._date_next + n, len(self._date_queue))
|
end = min(self._date_next + n, len(self._date_queue))
|
||||||
if self._date_next >= end:
|
if self._date_next >= end:
|
||||||
return
|
return
|
||||||
tasks = self._date_queue[self._date_next:end]
|
tasks = self._date_queue[self._date_next:end]
|
||||||
self._date_next = end
|
self._date_next = end
|
||||||
|
self._date_busy = True
|
||||||
self._start_date_loader(self._generation, tasks)
|
self._start_date_loader(self._generation, tasks)
|
||||||
|
|
||||||
def _start_date_loader(self, gen, tasks):
|
def _start_date_loader(self, gen, tasks):
|
||||||
@@ -2317,14 +2400,24 @@ class StorePage(QWidget):
|
|||||||
loader.moveToThread(thread)
|
loader.moveToThread(thread)
|
||||||
thread.started.connect(loader.run)
|
thread.started.connect(loader.run)
|
||||||
loader.loaded.connect(self._on_date_loaded)
|
loader.loaded.connect(self._on_date_loaded)
|
||||||
|
loader.finished.connect(self._on_date_batch_done)
|
||||||
loader.finished.connect(thread.quit)
|
loader.finished.connect(thread.quit)
|
||||||
thread.finished.connect(self._on_thread_finished)
|
thread.finished.connect(self._on_thread_finished)
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
self._loaders.append(loader)
|
self._loaders.append(loader)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
def _on_date_batch_done(self):
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
|
loader = self.sender()
|
||||||
|
if getattr(loader, "gen", None) != self._generation:
|
||||||
|
return
|
||||||
|
self._date_busy = False
|
||||||
|
self._load_date_batch(self.DATE_BATCH)
|
||||||
|
|
||||||
def _on_date_loaded(self, gen, row, date):
|
def _on_date_loaded(self, gen, row, date):
|
||||||
if gen != self._generation or not date:
|
if self._closing or gen != self._generation or not date:
|
||||||
return
|
return
|
||||||
if 0 <= row < len(self._filtered):
|
if 0 <= row < len(self._filtered):
|
||||||
self._filtered[row]["release_date"] = date
|
self._filtered[row]["release_date"] = date
|
||||||
@@ -2336,22 +2429,32 @@ class StorePage(QWidget):
|
|||||||
if self._closing or not tasks:
|
if self._closing or not tasks:
|
||||||
return
|
return
|
||||||
thread = QThread(self)
|
thread = QThread(self)
|
||||||
loader = StoreCoverLoader(tasks, gen, quick=True)
|
loader = StoreCoverLoader(tasks, gen, quick=True, icon_w=self.ICON_W, icon_h=self.ICON_H)
|
||||||
loader.moveToThread(thread)
|
loader.moveToThread(thread)
|
||||||
thread.started.connect(loader.run)
|
thread.started.connect(loader.run)
|
||||||
loader.loaded.connect(self._on_cover_loaded)
|
loader.loaded.connect(self._on_cover_loaded)
|
||||||
|
loader.finished.connect(self._on_cover_batch_done)
|
||||||
loader.finished.connect(thread.quit)
|
loader.finished.connect(thread.quit)
|
||||||
thread.finished.connect(self._on_thread_finished)
|
thread.finished.connect(self._on_thread_finished)
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
self._loaders.append(loader)
|
self._loaders.append(loader)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def _on_cover_loaded(self, gen, row, path):
|
def _on_cover_batch_done(self):
|
||||||
if gen != self._generation:
|
if self._closing:
|
||||||
|
return
|
||||||
|
loader = self.sender()
|
||||||
|
if getattr(loader, "gen", None) != self._generation:
|
||||||
|
return
|
||||||
|
self._cover_busy = False
|
||||||
|
self._load_cover_batch(self.COVER_SCROLL_BATCH)
|
||||||
|
|
||||||
|
def _on_cover_loaded(self, gen, row, image):
|
||||||
|
if self._closing or gen != self._generation:
|
||||||
return
|
return
|
||||||
item = self.grid.item(row)
|
item = self.grid.item(row)
|
||||||
if item is not None:
|
if item is not None:
|
||||||
item.setIcon(QIcon(_fit_pixmap(QPixmap(path), self.ICON_W, self.ICON_H)))
|
item.setIcon(QIcon(QPixmap.fromImage(image)))
|
||||||
it = item.data(Qt.UserRole)
|
it = item.data(Qt.UserRole)
|
||||||
shown = getattr(self.details, "_item", None)
|
shown = getattr(self.details, "_item", None)
|
||||||
if it is not None and shown is not None and it.get("path") == shown.get("path"):
|
if it is not None and shown is not None and it.get("path") == shown.get("path"):
|
||||||
@@ -2580,6 +2683,7 @@ class QueuePage(QWidget):
|
|||||||
"umd-dump": "UMD dump",
|
"umd-dump": "UMD dump",
|
||||||
"save-sync": "Save sync",
|
"save-sync": "Save sync",
|
||||||
"nps-download": "NoPayStation",
|
"nps-download": "NoPayStation",
|
||||||
|
"covers": "Covers bundle",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
@@ -4101,6 +4205,7 @@ class Ps1Page(QWidget):
|
|||||||
self._preview_gen = 0
|
self._preview_gen = 0
|
||||||
self._preview_threads = []
|
self._preview_threads = []
|
||||||
self._preview_workers = []
|
self._preview_workers = []
|
||||||
|
self._closing = False
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
bar = QHBoxLayout()
|
bar = QHBoxLayout()
|
||||||
@@ -4199,7 +4304,7 @@ class Ps1Page(QWidget):
|
|||||||
self._preview_workers.append(worker)
|
self._preview_workers.append(worker)
|
||||||
|
|
||||||
def _on_preview_result(self, gen, game_id, icon0, pic0, pic1):
|
def _on_preview_result(self, gen, game_id, icon0, pic0, pic1):
|
||||||
if gen != self._preview_gen:
|
if self._closing or gen != self._preview_gen:
|
||||||
return
|
return
|
||||||
self.preview_icon0.setText("")
|
self.preview_icon0.setText("")
|
||||||
self.preview_pic0.setText("")
|
self.preview_pic0.setText("")
|
||||||
@@ -4247,6 +4352,8 @@ class Ps1Page(QWidget):
|
|||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _on_results(self, entries):
|
def _on_results(self, entries):
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
# Only show discs that still need converting; converted games already
|
# Only show discs that still need converting; converted games already
|
||||||
# appear in the Library tab.
|
# appear in the Library tab.
|
||||||
entries = [e for e in entries if e["kind"] == "disc" and e.get("status") != "converted"]
|
entries = [e for e in entries if e["kind"] == "disc" and e.get("status") != "converted"]
|
||||||
@@ -4262,8 +4369,23 @@ class Ps1Page(QWidget):
|
|||||||
self.table.selectRow(0)
|
self.table.selectRow(0)
|
||||||
|
|
||||||
def _on_failed(self, message):
|
def _on_failed(self, message):
|
||||||
|
if not self._closing:
|
||||||
self.status.setText(f"PS1 scan failed: {message}")
|
self.status.setText(f"PS1 scan failed: {message}")
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self._closing = True
|
||||||
|
if self._thread is not None and _thread_running(self._thread):
|
||||||
|
self._thread.quit()
|
||||||
|
self._thread.wait()
|
||||||
|
for t in self._preview_threads:
|
||||||
|
if _thread_running(t):
|
||||||
|
t.quit()
|
||||||
|
t.wait()
|
||||||
|
self._preview_threads = []
|
||||||
|
self._preview_workers = []
|
||||||
|
self._thread = None
|
||||||
|
self._worker = None
|
||||||
|
|
||||||
def _convert_selected(self):
|
def _convert_selected(self):
|
||||||
row = self.table.currentRow()
|
row = self.table.currentRow()
|
||||||
if row < 0 or row >= len(self._entries):
|
if row < 0 or row >= len(self._entries):
|
||||||
@@ -4287,7 +4409,7 @@ class Ps1Page(QWidget):
|
|||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("psp preservation tool ~ quoije")
|
self.setWindowTitle("psppt ~ quoije")
|
||||||
self.resize(1240, 780)
|
self.resize(1240, 780)
|
||||||
self._rows = []
|
self._rows = []
|
||||||
self._renames = []
|
self._renames = []
|
||||||
@@ -4310,7 +4432,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._library_workers = []
|
self._library_workers = []
|
||||||
self._library_loading = False
|
self._library_loading = False
|
||||||
|
|
||||||
self.settings = QSettings("PSPPreserve", "PSPPreserve")
|
self.settings = QSettings("psppt", "psppt")
|
||||||
self._games_dir = self._first_existing(config.GAMES_ROOTS) or self.settings.value("games_dir", "") or ""
|
self._games_dir = self._first_existing(config.GAMES_ROOTS) or self.settings.value("games_dir", "") or ""
|
||||||
self._videos_dir = self._first_existing(config.VIDEO_ROOTS) or self.settings.value("videos_dir", "") or ""
|
self._videos_dir = self._first_existing(config.VIDEO_ROOTS) or self.settings.value("videos_dir", "") or ""
|
||||||
self._store_dir = str(self.settings.value("store_dir", str(config.STORE_ROOT)) or "")
|
self._store_dir = str(self.settings.value("store_dir", str(config.STORE_ROOT)) or "")
|
||||||
@@ -4353,7 +4475,7 @@ class MainWindow(QMainWindow):
|
|||||||
pm = QPixmap(str(icon_path))
|
pm = QPixmap(str(icon_path))
|
||||||
pm = pm.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
pm = pm.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||||
logo.setPixmap(pm)
|
logo.setPixmap(pm)
|
||||||
logo.setToolTip("PSP Preservation Tool")
|
logo.setToolTip("psppt")
|
||||||
self.tabs.setCornerWidget(logo, Qt.TopRightCorner)
|
self.tabs.setCornerWidget(logo, Qt.TopRightCorner)
|
||||||
|
|
||||||
self.queue = queue.TransferQueue(self)
|
self.queue = queue.TransferQueue(self)
|
||||||
@@ -4559,6 +4681,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp,
|
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp,
|
||||||
self._nps_dir)
|
self._nps_dir)
|
||||||
self.settings_page.saved.connect(self._on_settings_saved)
|
self.settings_page.saved.connect(self._on_settings_saved)
|
||||||
|
self.settings_page.download_covers.connect(self._download_covers_bundle)
|
||||||
return self.settings_page
|
return self.settings_page
|
||||||
|
|
||||||
def _on_settings_saved(self):
|
def _on_settings_saved(self):
|
||||||
@@ -4890,7 +5013,7 @@ class MainWindow(QMainWindow):
|
|||||||
if not self._nps_dir:
|
if not self._nps_dir:
|
||||||
QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.")
|
QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.")
|
||||||
return
|
return
|
||||||
sub = {"psp": "PSP", "psx": "PSX", "theme": "THEME", "dlc": "DLC"}.get(src.content_type, "PSP")
|
sub = {"psp": "PSP", "psx": "PSX", "theme": "THEME", "dlc": "DLC"}.get(it.get("content_type") or src.content_type, "PSP")
|
||||||
dest_dir = Path(self._nps_dir) / sub
|
dest_dir = Path(self._nps_dir) / sub
|
||||||
kind = "nps-download"
|
kind = "nps-download"
|
||||||
else:
|
else:
|
||||||
@@ -5296,6 +5419,9 @@ class MainWindow(QMainWindow):
|
|||||||
QTimer.singleShot(0, self._refresh_psp)
|
QTimer.singleShot(0, self._refresh_psp)
|
||||||
if kind == "save-sync":
|
if kind == "save-sync":
|
||||||
QTimer.singleShot(0, self._refresh_saves)
|
QTimer.singleShot(0, self._refresh_saves)
|
||||||
|
if kind == "covers":
|
||||||
|
covers.clear_miss_cache()
|
||||||
|
QTimer.singleShot(0, self.load_library)
|
||||||
if kind == "ps1-convert":
|
if kind == "ps1-convert":
|
||||||
QTimer.singleShot(0, self.ps1_page.refresh)
|
QTimer.singleShot(0, self.ps1_page.refresh)
|
||||||
QTimer.singleShot(0, self.load_library)
|
QTimer.singleShot(0, self.load_library)
|
||||||
@@ -5631,6 +5757,48 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.information(self, "Done", f"Renamed {renamed} file(s).")
|
QMessageBox.information(self, "Done", f"Renamed {renamed} file(s).")
|
||||||
self.load_library()
|
self.load_library()
|
||||||
|
|
||||||
|
def maybe_offer_covers_bundle(self):
|
||||||
|
"""Offer to download the offline cover bundle once, if it's missing."""
|
||||||
|
if self._closing or covers.covers_bundle_present():
|
||||||
|
return
|
||||||
|
s = QSettings("psppt", "psppt")
|
||||||
|
if str(s.value("covers_bundle_skip", "false")).lower() == "true":
|
||||||
|
return
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setWindowTitle("Offline covers")
|
||||||
|
box.setIcon(QMessageBox.Question)
|
||||||
|
box.setText(
|
||||||
|
"The offline cover bundle (~843 MB) isn't installed.\n\n"
|
||||||
|
"Download it now from the project repo? Covers keep working online "
|
||||||
|
"without it — the bundle just makes them instant and offline."
|
||||||
|
)
|
||||||
|
download = box.addButton("Download", QMessageBox.AcceptRole)
|
||||||
|
box.addButton("Not now", QMessageBox.RejectRole)
|
||||||
|
never = box.addButton("Don't ask again", QMessageBox.DestructiveRole)
|
||||||
|
box.setDefaultButton(download)
|
||||||
|
box.exec()
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
if clicked is never:
|
||||||
|
s.setValue("covers_bundle_skip", "true")
|
||||||
|
elif clicked is download:
|
||||||
|
self._download_covers_bundle()
|
||||||
|
|
||||||
|
def _download_covers_bundle(self):
|
||||||
|
"""Queue the offline cover bundle download in the transfer queue."""
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
|
for j in self.queue.jobs:
|
||||||
|
if j.kind == "covers" and j.status in ("queued", "running", "cancelling"):
|
||||||
|
return # already queued or running
|
||||||
|
self.queue.enqueue(
|
||||||
|
transfers.CoversBundleSource(),
|
||||||
|
str(config.COVERS_DIR),
|
||||||
|
None,
|
||||||
|
"Offline cover bundle",
|
||||||
|
kind="covers",
|
||||||
|
)
|
||||||
|
self.tabs.setCurrentWidget(self.queue_page)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
self._closing = True
|
self._closing = True
|
||||||
self.settings.setValue("window_geometry", self.saveGeometry())
|
self.settings.setValue("window_geometry", self.saveGeometry())
|
||||||
@@ -5656,18 +5824,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.psp_page.shutdown()
|
self.psp_page.shutdown()
|
||||||
if hasattr(self, "saves_page"):
|
if hasattr(self, "saves_page"):
|
||||||
self.saves_page.shutdown()
|
self.saves_page.shutdown()
|
||||||
|
if hasattr(self, "ps1_page"):
|
||||||
|
self.ps1_page.shutdown()
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
def _start_bundle_check(window):
|
|
||||||
"""Download any missing offline cover bundles in the background."""
|
|
||||||
server = getattr(window, "_store_server_url", "") or ""
|
|
||||||
try:
|
|
||||||
covers.ensure_cover_bundles(server)
|
|
||||||
except Exception: # noqa: BLE001 - background best-effort
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s")
|
||||||
init_db() # migrate legacy on-share DB/covers to local storage, then schema
|
init_db() # migrate legacy on-share DB/covers to local storage, then schema
|
||||||
@@ -5676,11 +5837,11 @@ def main():
|
|||||||
n = ingest.ensure_catalog()
|
n = ingest.ensure_catalog()
|
||||||
if n:
|
if n:
|
||||||
log.info("ingested %d DAT(s) into the catalog", n)
|
log.info("ingested %d DAT(s) into the catalog", n)
|
||||||
log.info("starting psp-preserve (db: %s)", config.DB_PATH)
|
log.info("starting psppt (db: %s)", config.DB_PATH)
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
style.apply_theme(app)
|
style.apply_theme(app)
|
||||||
app.setApplicationName("PSP Preservation Tool")
|
app.setApplicationName("psppt")
|
||||||
app.setDesktopFileName("psp-preserve")
|
app.setDesktopFileName("psppt")
|
||||||
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
|
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
|
||||||
if icon_path.is_file():
|
if icon_path.is_file():
|
||||||
app.setWindowIcon(QIcon(str(icon_path)))
|
app.setWindowIcon(QIcon(str(icon_path)))
|
||||||
@@ -5688,7 +5849,22 @@ def main():
|
|||||||
window.setWindowIcon(app.windowIcon())
|
window.setWindowIcon(app.windowIcon())
|
||||||
window.show()
|
window.show()
|
||||||
log.info("main window ready")
|
log.info("main window ready")
|
||||||
threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start()
|
QTimer.singleShot(1200, window.maybe_offer_covers_bundle)
|
||||||
|
|
||||||
|
# Ctrl+C should close the window gracefully (running closeEvent's thread
|
||||||
|
# cleanup) rather than tearing Qt down mid-thread, which segfaults.
|
||||||
|
interrupt = {"pending": False}
|
||||||
|
|
||||||
|
def _sigint_handler(signum, frame):
|
||||||
|
interrupt["pending"] = True
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _sigint_handler)
|
||||||
|
sigint_timer = QTimer()
|
||||||
|
sigint_timer.setInterval(100)
|
||||||
|
sigint_timer.timeout.connect(
|
||||||
|
lambda: window.close() if interrupt["pending"] else None)
|
||||||
|
sigint_timer.start()
|
||||||
|
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -12,7 +12,7 @@ import json
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from . import config, covers
|
from . import config, covers, server_client
|
||||||
from .db import get_connection
|
from .db import get_connection
|
||||||
|
|
||||||
_failed = {} # (release_id, system) -> time.time() of last failed fetch
|
_failed = {} # (release_id, system) -> time.time() of last failed fetch
|
||||||
@@ -199,6 +199,21 @@ def get_or_fetch(release_id, system, fetch_title):
|
|||||||
info = load(release_id, system)
|
info = load(release_id, system)
|
||||||
if info is not None:
|
if info is not None:
|
||||||
return info
|
return info
|
||||||
|
# Server is authoritative for game/video metadata when configured — no
|
||||||
|
# IGDB/TMDB fallback; an unreachable server just leaves metadata empty.
|
||||||
|
# PS1 metadata stays client-side (the server catalog only covers games
|
||||||
|
# and videos).
|
||||||
|
if server_client.configured() and system in ("games", "videos"):
|
||||||
|
try:
|
||||||
|
meta = server_client.fetch_metadata(system, fetch_title)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("server metadata fetch failed for %r: %s", fetch_title, exc)
|
||||||
|
return None
|
||||||
|
if meta:
|
||||||
|
save(release_id, system, meta, manual=False)
|
||||||
|
meta["manual"] = False
|
||||||
|
return meta
|
||||||
|
return None
|
||||||
key = (str(release_id), system)
|
key = (str(release_id), system)
|
||||||
last = _failed.get(key)
|
last = _failed.get(key)
|
||||||
if last is not None and time.time() - last < _FAIL_TTL:
|
if last is not None and time.time() - last < _FAIL_TTL:
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
"""PSP Preservation Tool entry point (native GUI).
|
"""psppt entry point (native GUI).
|
||||||
|
|
||||||
Run with::
|
Run with::
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -151,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")
|
||||||
|
|||||||
+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())
|
||||||
|
|
||||||
|
|||||||
+35
-32
@@ -79,13 +79,13 @@ class TransferQueue(QObject):
|
|||||||
job_finished = Signal(dict)
|
job_finished = Signal(dict)
|
||||||
job_removed = Signal(int)
|
job_removed = Signal(int)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None, max_concurrent=3):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.max_concurrent = max(1, int(max_concurrent))
|
||||||
self.jobs = []
|
self.jobs = []
|
||||||
self._pending = []
|
self._pending = []
|
||||||
self._current = None
|
# job_id -> (QThread, JobWorker) for jobs currently running in parallel.
|
||||||
self._thread = None
|
self._running = {}
|
||||||
self._worker = None
|
|
||||||
# Keep strong references to every worker/thread for their full lifetime.
|
# Keep strong references to every worker/thread for their full lifetime.
|
||||||
# Dropping a Python ref to a QObject that was moved to another thread
|
# Dropping a Python ref to a QObject that was moved to another thread
|
||||||
# deletes it directly and crashes (bus error).
|
# deletes it directly and crashes (bus error).
|
||||||
@@ -115,8 +115,10 @@ class TransferQueue(QObject):
|
|||||||
self.job_updated.emit(job.to_dict())
|
self.job_updated.emit(job.to_dict())
|
||||||
self.job_finished.emit(job.to_dict())
|
self.job_finished.emit(job.to_dict())
|
||||||
return
|
return
|
||||||
if job.status == "running" and self._worker is not None:
|
if job.status == "running":
|
||||||
self._worker.cancel()
|
pair = self._running.get(job_id)
|
||||||
|
if pair is not None:
|
||||||
|
pair[1].cancel()
|
||||||
job.status = "cancelling"
|
job.status = "cancelling"
|
||||||
self.job_updated.emit(job.to_dict())
|
self.job_updated.emit(job.to_dict())
|
||||||
|
|
||||||
@@ -140,8 +142,8 @@ class TransferQueue(QObject):
|
|||||||
self.remove(jid)
|
self.remove(jid)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
if self._worker is not None:
|
for _t, worker in self._running.values():
|
||||||
self._worker.cancel()
|
worker.cancel()
|
||||||
for t in self._threads:
|
for t in self._threads:
|
||||||
try:
|
try:
|
||||||
if t.isRunning():
|
if t.isRunning():
|
||||||
@@ -157,30 +159,29 @@ class TransferQueue(QObject):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _start_next(self):
|
def _start_next(self):
|
||||||
if self._current is not None or not self._pending:
|
while len(self._running) < self.max_concurrent and self._pending:
|
||||||
return
|
|
||||||
job = self._pending.pop(0)
|
job = self._pending.pop(0)
|
||||||
self._current = job
|
|
||||||
job.status = "running"
|
job.status = "running"
|
||||||
log.info("starting job #%d: %s (%s)", job.id, job.title, job.kind)
|
log.info("starting job #%d: %s (%s)", job.id, job.title, job.kind)
|
||||||
self.job_updated.emit(job.to_dict())
|
self.job_updated.emit(job.to_dict())
|
||||||
|
|
||||||
self._thread = QThread(self)
|
thread = QThread(self)
|
||||||
self._worker = JobWorker(job.source, job.dest_dir, job)
|
worker = JobWorker(job.source, job.dest_dir, job)
|
||||||
self._worker.moveToThread(self._thread)
|
worker.moveToThread(thread)
|
||||||
self._thread.started.connect(self._worker.run)
|
thread.started.connect(worker.run)
|
||||||
self._worker.progress.connect(self._on_progress)
|
worker.progress.connect(self._on_progress)
|
||||||
self._worker.finished.connect(self._on_finished)
|
worker.finished.connect(self._on_finished)
|
||||||
self._worker.skipped.connect(self._on_skipped)
|
worker.skipped.connect(self._on_skipped)
|
||||||
self._worker.cancelled.connect(self._on_cancelled)
|
worker.cancelled.connect(self._on_cancelled)
|
||||||
self._worker.failed.connect(self._on_failed)
|
worker.failed.connect(self._on_failed)
|
||||||
for sig in (self._worker.finished, self._worker.skipped,
|
for sig in (worker.finished, worker.skipped,
|
||||||
self._worker.cancelled, self._worker.failed):
|
worker.cancelled, worker.failed):
|
||||||
sig.connect(self._thread.quit)
|
sig.connect(thread.quit)
|
||||||
self._thread.finished.connect(self._on_thread_finished)
|
thread.finished.connect(self._on_thread_finished)
|
||||||
self._threads.append(self._thread)
|
self._running[job.id] = (thread, worker)
|
||||||
self._workers.append(self._worker)
|
self._threads.append(thread)
|
||||||
self._thread.start()
|
self._workers.append(worker)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
def _on_progress(self, job_id, stage, fraction, detail):
|
def _on_progress(self, job_id, stage, fraction, detail):
|
||||||
job = self._find(job_id)
|
job = self._find(job_id)
|
||||||
@@ -218,10 +219,12 @@ class TransferQueue(QObject):
|
|||||||
self.job_finished.emit(job.to_dict())
|
self.job_finished.emit(job.to_dict())
|
||||||
|
|
||||||
def _on_thread_finished(self):
|
def _on_thread_finished(self):
|
||||||
# Queued to the main thread once the worker thread has fully stopped,
|
# Queued to the main thread once a worker thread has fully stopped, so
|
||||||
# so it is now safe to drop the "current" pointers and advance.
|
# it is now safe to drop its pointers and advance the queue.
|
||||||
self._current = None
|
thread = self.sender()
|
||||||
self._thread = None
|
for jid, pair in list(self._running.items()):
|
||||||
self._worker = None
|
if pair[0] is thread:
|
||||||
|
del self._running[jid]
|
||||||
|
break
|
||||||
self._start_next()
|
self._start_next()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Client-side HTTP access to the psppt server (auth + content fetches).
|
||||||
|
|
||||||
|
The server is authoritative for covers and metadata. When a server is
|
||||||
|
configured (URL + per-user token), the cover/metadata paths use it and show an
|
||||||
|
error rather than falling back to libretro/IGDB/etc.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
log = logging.getLogger("server_client")
|
||||||
|
|
||||||
|
_CFG_FILE = config.STATE_DIR / "server.json"
|
||||||
|
_cache = {"loaded": False, "cfg": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
if not _cache["loaded"]:
|
||||||
|
cfg = {}
|
||||||
|
try:
|
||||||
|
cfg = json.loads(_CFG_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
cfg = {}
|
||||||
|
_cache["cfg"] = cfg
|
||||||
|
_cache["loaded"] = True
|
||||||
|
return _cache["cfg"]
|
||||||
|
|
||||||
|
|
||||||
|
def _save(cfg):
|
||||||
|
_CFG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_CFG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||||
|
_cache["cfg"] = cfg
|
||||||
|
_cache["loaded"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def configured():
|
||||||
|
cfg = _load()
|
||||||
|
return bool(cfg.get("url") and cfg.get("token"))
|
||||||
|
|
||||||
|
|
||||||
|
def url():
|
||||||
|
return (_load().get("url") or config.STORE_SERVER_URL or "").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
return _load().get("token") or config.STORE_TOKEN or ""
|
||||||
|
|
||||||
|
|
||||||
|
def username():
|
||||||
|
return _load().get("username") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def logout():
|
||||||
|
_save({"url": url(), "token": "", "username": ""})
|
||||||
|
|
||||||
|
|
||||||
|
def login(server_url, user, password):
|
||||||
|
"""Authenticate against the server and store the per-user token."""
|
||||||
|
base = (server_url or "").rstrip("/")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base}/api/auth/login",
|
||||||
|
data=json.dumps({"username": user, "password": password}).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json", "User-Agent": "psppt/0.1"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
_save({"url": base, "token": data.get("token") or "", "username": data.get("username") or user})
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path, timeout=15):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{url()}{path}",
|
||||||
|
headers={"Authorization": f"Bearer {token()}", "User-Agent": "psppt/0.1"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_id(system, name):
|
||||||
|
"""Return the server release id for a name (first match), or None."""
|
||||||
|
q = urllib.parse.quote(name or "")
|
||||||
|
data = json.loads(_get(f"/api/releases?system={system}&q={q}&limit=1", timeout=15))
|
||||||
|
items = data.get("items") or []
|
||||||
|
return items[0]["id"] if items else None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_cover(system, name):
|
||||||
|
"""Return cover bytes for a release, or None (no cover on the server)."""
|
||||||
|
rid = _lookup_id(system, name)
|
||||||
|
if rid is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _get(f"/api/cover/{rid}", timeout=30)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_metadata(system, name):
|
||||||
|
"""Return metadata dict for a release, or None."""
|
||||||
|
rid = _lookup_id(system, name)
|
||||||
|
if rid is None:
|
||||||
|
return None
|
||||||
|
detail = json.loads(_get(f"/api/releases/{rid}", timeout=15))
|
||||||
|
return detail.get("meta") or {}
|
||||||
+50
-9
@@ -175,6 +175,33 @@ class Source:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class CoversBundleSource:
|
||||||
|
"""Download the combined offline cover bundle (runs as a queue job)."""
|
||||||
|
label = "psppt-covers.zip"
|
||||||
|
|
||||||
|
def __init__(self, url=""):
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
|
||||||
|
from . import covers
|
||||||
|
|
||||||
|
def on_progress(done, total):
|
||||||
|
if progress_cb is None:
|
||||||
|
return
|
||||||
|
frac = (done / total) if total else 0.0
|
||||||
|
detail = (f"{done / (1 << 20):.0f} / {total / (1 << 20):.0f} MB"
|
||||||
|
if total else "")
|
||||||
|
progress_cb("Downloading", frac, detail)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = covers.download_covers_bundle(self.url,
|
||||||
|
progress_cb=on_progress,
|
||||||
|
cancel_event=cancel_event)
|
||||||
|
except InterruptedError:
|
||||||
|
raise TransferCancelled() from None
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
|
||||||
class FileCopySource(Source):
|
class FileCopySource(Source):
|
||||||
"""Copy a single local file (library -> PSP or PSP -> library).
|
"""Copy a single local file (library -> PSP or PSP -> library).
|
||||||
|
|
||||||
@@ -279,12 +306,13 @@ class RemoteStoreSource(Source):
|
|||||||
|
|
||||||
label = "Remote store"
|
label = "Remote store"
|
||||||
|
|
||||||
def __init__(self, base_url, token=""):
|
def __init__(self, base_url, token="", system="games"):
|
||||||
self.base_url = (base_url or "").rstrip("/")
|
self.base_url = (base_url or "").rstrip("/")
|
||||||
self.token = token or ""
|
self.token = token or ""
|
||||||
|
self.system = system or "games"
|
||||||
|
|
||||||
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
|
||||||
@@ -292,10 +320,13 @@ class RemoteStoreSource(Source):
|
|||||||
def scan(self, root=None):
|
def scan(self, root=None):
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return []
|
return []
|
||||||
req = urllib.request.Request(f"{self.base_url}/api/store", headers=self._headers())
|
req = urllib.request.Request(f"{self.base_url}/api/store?system={self.system}", headers=self._headers())
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
return data.get("items", []) if isinstance(data, dict) else []
|
items = data.get("items", []) if isinstance(data, dict) else []
|
||||||
|
for it in items:
|
||||||
|
it["system"] = it.get("system") or self.system
|
||||||
|
return items
|
||||||
|
|
||||||
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
|
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
|
||||||
dest_dir = Path(dest_dir)
|
dest_dir = Path(dest_dir)
|
||||||
@@ -303,10 +334,11 @@ class RemoteStoreSource(Source):
|
|||||||
# The listing's "name" has no extension; "filename" carries the real
|
# The listing's "name" has no extension; "filename" carries the real
|
||||||
# archive filename (e.g. "Game (USA).7z") used for the download URL.
|
# archive filename (e.g. "Game (USA).7z") used for the download URL.
|
||||||
name = item.get("filename") or item["name"]
|
name = item.get("filename") or item["name"]
|
||||||
|
system = item.get("system") or self.system
|
||||||
tmp = Path(tempfile.mkdtemp(prefix="psp-store-"))
|
tmp = Path(tempfile.mkdtemp(prefix="psp-store-"))
|
||||||
archive = tmp / name
|
archive = tmp / name
|
||||||
try:
|
try:
|
||||||
url = f"{self.base_url}/api/store/download/{urllib.parse.quote(name, safe='')}"
|
url = f"{self.base_url}/api/store/download/{system}/{urllib.parse.quote(name, safe='')}"
|
||||||
download_url(url, archive, self._headers(), progress_cb, cancel_event)
|
download_url(url, archive, self._headers(), progress_cb, cancel_event)
|
||||||
iso_name = peek_archive(archive, cancel_event)
|
iso_name = peek_archive(archive, cancel_event)
|
||||||
if not iso_name:
|
if not iso_name:
|
||||||
@@ -347,7 +379,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 "",
|
||||||
@@ -479,11 +511,19 @@ class NoPayStationSource(Source):
|
|||||||
def __init__(self, content_type, tsv_url="", 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 ("" if content_type == "all" else nopaystation.tsv_url(content_type))
|
||||||
self.force = force
|
self.force = force
|
||||||
|
|
||||||
def scan(self, root=None):
|
def scan(self, root=None):
|
||||||
from . import nopaystation
|
from . import nopaystation
|
||||||
|
if self.content_type == "all":
|
||||||
|
items = []
|
||||||
|
for ct in ("psp", "psx", "theme", "dlc"):
|
||||||
|
try:
|
||||||
|
items.extend(nopaystation.fetch_items(ct, force=self.force))
|
||||||
|
except Exception: # noqa: BLE001 - skip a source that fails
|
||||||
|
log.warning("could not fetch NoPayStation %s list", ct)
|
||||||
|
return items
|
||||||
return nopaystation.fetch_items(self.content_type, self.tsv_url, force=self.force)
|
return nopaystation.fetch_items(self.content_type, self.tsv_url, force=self.force)
|
||||||
|
|
||||||
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
|
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
|
||||||
@@ -493,6 +533,7 @@ class NoPayStationSource(Source):
|
|||||||
|
|
||||||
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly.
|
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly.
|
||||||
title_id = item.get("title_id") or item.get("content_id") or ""
|
title_id = item.get("title_id") or item.get("content_id") or ""
|
||||||
|
item_ct = item.get("content_type") or self.content_type
|
||||||
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-"))
|
||||||
pkg = tmp / f"{pkg_name}.pkg"
|
pkg = tmp / f"{pkg_name}.pkg"
|
||||||
@@ -500,10 +541,10 @@ class NoPayStationSource(Source):
|
|||||||
if progress_cb is not None:
|
if progress_cb is not None:
|
||||||
progress_cb("Downloading", 0.0, "")
|
progress_cb("Downloading", 0.0, "")
|
||||||
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
||||||
kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None
|
kind_hint = item_ct if item_ct in ("theme", "dlc") else None
|
||||||
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir,
|
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 item_ct in ("theme", "dlc"):
|
||||||
_write_sidecar(folder, item)
|
_write_sidecar(folder, item)
|
||||||
return folder
|
return folder
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+7
-6
@@ -1,7 +1,7 @@
|
|||||||
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:
|
||||||
@@ -15,13 +15,14 @@ services:
|
|||||||
PSP_GAMES_ROOTS: /data/umd_games
|
PSP_GAMES_ROOTS: /data/umd_games
|
||||||
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>`.
|
PSP_STORE_VIDEOS_DIR: /data/store_videos
|
||||||
# Set in the .env file next to this compose file.
|
PSP_ADMIN_USERNAME: ${PSP_ADMIN_USERNAME:-admin}
|
||||||
PSP_API_TOKEN: ${PSP_API_TOKEN:-}
|
PSP_ADMIN_PASSWORD: ${PSP_ADMIN_PASSWORD:-admin}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
# Host paths come from .env (see .env.example):
|
# Host paths come from .env (see .env.example):
|
||||||
- ${PSP_STORE_HOST}:/data/store:ro
|
- ${PSP_STORE_HOST}:/data/store
|
||||||
|
- ${PSP_STORE_VIDEOS_HOST}:/data/store_videos
|
||||||
- ${PSP_UMD_GAMES_HOST}:/data/umd_games:ro
|
- ${PSP_UMD_GAMES_HOST}:/data/umd_games:ro
|
||||||
- ${PSP_UMD_VIDEOS_HOST}:/data/umd_videos:ro
|
- ${PSP_UMD_VIDEOS_HOST}:/data/umd_videos:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 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"
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=PSP Preservation Tool
|
Name=psppt
|
||||||
Comment=Preserve and manage your PSP library
|
Comment=Preserve and manage your PSP library
|
||||||
Exec=psp-preserve
|
Exec=psppt
|
||||||
Icon=psp-preserve
|
Icon=psppt
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Utility;AudioVideo;
|
Categories=Utility;AudioVideo;
|
||||||
StartupWMClass=psp-preserve
|
StartupWMClass=psppt
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
"""PyInstaller spec: build the PSP Preservation Tool (onedir).
|
"""PyInstaller spec: build psppt (onedir).
|
||||||
|
|
||||||
Bundles the read-only resources (DATs, PS1 loader, offline cover bundles,
|
Bundles the read-only resources (DATs, PS1 loader, cover templates, index
|
||||||
icons) under ``_internal/`` next to the executable. User-writable data
|
catalogs, icons) under ``lib/`` next to the executable. User-writable data
|
||||||
(credentials, manual covers, PS1 templates, NPS cache, library DB, cover
|
(credentials, manual covers, PS1 templates, NPS cache, library DB, cover
|
||||||
cache) lives in the OS state dir at runtime — see ``app/config.py``.
|
cache) lives in the OS state dir at runtime — see ``app/config.py``.
|
||||||
|
|
||||||
Usage: pyinstaller --clean --noconfirm psp-preserve.spec
|
Usage: pyinstaller --clean --noconfirm psppt.spec
|
||||||
Output: dist/psp-preserve/
|
Output: dist/psppt/
|
||||||
"""
|
"""
|
||||||
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules
|
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules
|
||||||
|
|
||||||
@@ -15,8 +15,6 @@ datas = [
|
|||||||
("data/dats", "data/dats"),
|
("data/dats", "data/dats"),
|
||||||
("data/loader", "data/loader"),
|
("data/loader", "data/loader"),
|
||||||
("data/supplement.json", "data"),
|
("data/supplement.json", "data"),
|
||||||
("data/covers/libretro-covers.zip", "data/covers"),
|
|
||||||
("data/covers/openvgdb-covers.zip", "data/covers"),
|
|
||||||
("data/covers/libretro-index.json", "data/covers"),
|
("data/covers/libretro-index.json", "data/covers"),
|
||||||
("data/covers/openvgdb-index.json", "data/covers"),
|
("data/covers/openvgdb-index.json", "data/covers"),
|
||||||
("data/covers/UMD_COVER_TEMPLATE.png", "data/covers"),
|
("data/covers/UMD_COVER_TEMPLATE.png", "data/covers"),
|
||||||
@@ -24,7 +22,7 @@ datas = [
|
|||||||
("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"),
|
("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"),
|
||||||
("img/app-icon.png", "img"),
|
("img/app-icon.png", "img"),
|
||||||
("img/chevron.png", "img"),
|
("img/chevron.png", "img"),
|
||||||
("packaging/psp-preserve.desktop", "."),
|
("packaging/psppt.desktop", "."),
|
||||||
]
|
]
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
@@ -47,14 +45,14 @@ exe = EXE(
|
|||||||
a.scripts,
|
a.scripts,
|
||||||
[],
|
[],
|
||||||
exclude_binaries=True,
|
exclude_binaries=True,
|
||||||
name="psp-preserve",
|
name="psppt",
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=False,
|
upx=False,
|
||||||
console=False,
|
console=False,
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
contents_directory="psp-preserve",
|
contents_directory="lib",
|
||||||
)
|
)
|
||||||
|
|
||||||
coll = COLLECT(
|
coll = COLLECT(
|
||||||
@@ -63,5 +61,5 @@ coll = COLLECT(
|
|||||||
a.datas,
|
a.datas,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=False,
|
upx=False,
|
||||||
name="psp-preserve",
|
name="psppt",
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.110
|
||||||
|
uvicorn[standard]>=0.29
|
||||||
|
jinja2>=3.1
|
||||||
|
python-multipart>=0.0.9
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""psppt server package (FastAPI + admin WebUI)."""
|
||||||
+491
@@ -0,0 +1,491 @@
|
|||||||
|
"""REST API for the psppt server (JSON, token-authenticated).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/auth/login -> {token, username, role}
|
||||||
|
GET /api/auth/me -> current user
|
||||||
|
POST /api/auth/logout -> revoke current token
|
||||||
|
GET /api/users -> list (admin)
|
||||||
|
POST /api/users -> create (admin)
|
||||||
|
PATCH /api/users/{id} -> update role/password/active (admin)
|
||||||
|
DELETE /api/users/{id} -> delete (admin)
|
||||||
|
GET /api/users/{id}/tokens -> list API tokens (admin or self)
|
||||||
|
DELETE /api/users/{id}/tokens/{tid} -> revoke a token (admin or self)
|
||||||
|
GET /api/releases?system=&q=&limit= -> catalog list
|
||||||
|
GET /api/releases/{id} -> detail + metadata + roms
|
||||||
|
PATCH /api/releases/{id} -> edit metadata (editor+)
|
||||||
|
GET /api/cover/{id} -> cover image
|
||||||
|
POST /api/cover/{id} -> upload/replace cover (editor+)
|
||||||
|
DELETE /api/cover/{id} -> delete cover (editor+)
|
||||||
|
GET /api/store?system=games|videos -> archive list
|
||||||
|
GET /api/store/download/{system}/{name} -> stream archive
|
||||||
|
POST /api/store/upload?system= -> upload archive (editor+)
|
||||||
|
DELETE /api/store/{system}/{name} -> delete archive (editor+)
|
||||||
|
GET /api/stats -> dashboard numbers (admin)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Header, HTTPException, Query, Request, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
|
from app import covers, store
|
||||||
|
from app.db import get_connection
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .security import now_iso, require_role, revoke_token, get_current_user, hash_password, verify_password, issue_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
_META_FIELDS = ("release_date", "developer", "publisher", "genre", "synopsis", "title")
|
||||||
|
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._()\- ]")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _conn():
|
||||||
|
return get_connection()
|
||||||
|
|
||||||
|
|
||||||
|
def _release_system(conn, release_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT d.system FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?",
|
||||||
|
(release_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row["system"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata(conn, release_id, system):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT title, meta_json, manual FROM item_info WHERE release_id = ? AND system = ?",
|
||||||
|
(str(release_id), system),
|
||||||
|
).fetchone()
|
||||||
|
meta = {}
|
||||||
|
if row and row["meta_json"]:
|
||||||
|
try:
|
||||||
|
meta = json.loads(row["meta_json"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
meta = {}
|
||||||
|
if row:
|
||||||
|
meta["title"] = row["title"]
|
||||||
|
meta["manual"] = bool(row["manual"])
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def _save_metadata(conn, release_id, system, fields):
|
||||||
|
key = str(release_id)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT meta_json FROM item_info WHERE release_id = ? AND system = ?", (key, system)
|
||||||
|
).fetchone()
|
||||||
|
meta = {}
|
||||||
|
if row and row["meta_json"]:
|
||||||
|
try:
|
||||||
|
meta = json.loads(row["meta_json"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
meta = {}
|
||||||
|
meta.update(fields)
|
||||||
|
title = fields.get("title") or meta.get("title") or ""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO item_info (release_id, system, title, meta_json, manual, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, 1, ?) "
|
||||||
|
"ON CONFLICT(release_id, system) DO UPDATE SET "
|
||||||
|
"title = excluded.title, meta_json = excluded.meta_json, manual = 1, updated_at = excluded.updated_at",
|
||||||
|
(key, system, title, json.dumps(meta, ensure_ascii=False), now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_paths(release_id, system):
|
||||||
|
base = config.COVERS_DIR / system / str(release_id)
|
||||||
|
return base, [base.with_suffix(ext) for ext in (".png", ".jpg", ".jpeg", ".webp")]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_cover(release_id, system):
|
||||||
|
base, candidates = _cover_paths(release_id, system)
|
||||||
|
for p in candidates:
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
return base.with_suffix(".png")
|
||||||
|
|
||||||
|
|
||||||
|
def _save_cover(release_id, system, data):
|
||||||
|
base, candidates = _cover_paths(release_id, system)
|
||||||
|
base.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ext = ".png"
|
||||||
|
if data[:3] == b"\xff\xd8\xff":
|
||||||
|
ext = ".jpg"
|
||||||
|
elif data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||||
|
ext = ".webp"
|
||||||
|
dest = base.with_suffix(ext)
|
||||||
|
for p in candidates:
|
||||||
|
if p.exists() and p != dest:
|
||||||
|
p.unlink()
|
||||||
|
tmp = dest.with_name(dest.name + ".tmp")
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
tmp.replace(dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _store_root(system):
|
||||||
|
return config.STORE_VIDEOS_ROOT if system == "videos" else config.STORE_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def _log_download(user, system, name, size):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO download_events (user_id, system, name, bytes, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user["id"] if user else None, system, name, size or 0, now_iso()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# auth
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.post("/auth/login")
|
||||||
|
def auth_login(payload: dict):
|
||||||
|
username = (payload.get("username") or "").strip()
|
||||||
|
password = payload.get("password") or ""
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, username, password_hash, role, is_active FROM users WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if row is None or not row["is_active"] or not verify_password(password, row["password_hash"]):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
token = issue_token(row["id"], kind="api", label="desktop")
|
||||||
|
return {"token": token, "username": row["username"], "role": row["role"], "user_id": row["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/me")
|
||||||
|
def auth_me(user=Depends(get_current_user)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/logout")
|
||||||
|
def auth_logout(request: Request, authorization: str = Header(default="")):
|
||||||
|
token = ""
|
||||||
|
if authorization.startswith("Bearer "):
|
||||||
|
token = authorization[7:].strip()
|
||||||
|
else:
|
||||||
|
token = request.cookies.get("psppt_session", "")
|
||||||
|
if token:
|
||||||
|
revoke_token(token)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# users (admin)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/users")
|
||||||
|
def users_list(user=Depends(require_role("admin"))):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, username, role, is_active, created_at, last_login_at FROM users ORDER BY username"
|
||||||
|
).fetchall()
|
||||||
|
return {"items": [dict(r) for r in rows]}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
def users_create(payload: dict, user=Depends(require_role("admin"))):
|
||||||
|
username = (payload.get("username") or "").strip()
|
||||||
|
password = payload.get("password") or ""
|
||||||
|
role = payload.get("role") or "user"
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(status_code=400, detail="username and password are required")
|
||||||
|
if role not in ("admin", "editor", "user"):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid role")
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, role, is_active, created_at) VALUES (?, ?, ?, 1, ?)",
|
||||||
|
(username, hash_password(password), role, now_iso()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=409, detail="username already exists")
|
||||||
|
row = conn.execute("SELECT id, username, role, is_active FROM users WHERE username = ?", (username,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}")
|
||||||
|
def users_update(user_id: int, payload: dict, user=Depends(require_role("admin"))):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
if "role" in payload:
|
||||||
|
if payload["role"] not in ("admin", "editor", "user"):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid role")
|
||||||
|
conn.execute("UPDATE users SET role = ? WHERE id = ?", (payload["role"], user_id))
|
||||||
|
if "is_active" in payload:
|
||||||
|
conn.execute("UPDATE users SET is_active = ? WHERE id = ?", (1 if payload["is_active"] else 0, user_id))
|
||||||
|
if payload.get("password"):
|
||||||
|
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(payload["password"]), user_id))
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT id, username, role, is_active FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
def users_delete(user_id: int, user=Depends(require_role("admin"))):
|
||||||
|
if user_id == user["id"]:
|
||||||
|
raise HTTPException(status_code=400, detail="cannot delete yourself")
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/tokens")
|
||||||
|
def users_tokens(user_id: int, user=Depends(require_role("admin"))):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, label, kind, created_at, last_used_at, expires_at, revoked_at FROM api_tokens "
|
||||||
|
"WHERE user_id = ? ORDER BY id DESC",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {"items": [dict(r) for r in rows]}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}/tokens/{token_id}")
|
||||||
|
def users_revoke_token(user_id: int, token_id: int, user=Depends(require_role("admin"))):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ?",
|
||||||
|
(now_iso(), token_id, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# releases / catalog
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/releases")
|
||||||
|
def releases_list(system: str = Query(default=""), q: str = Query(default=""), limit: int = 50, offset: int = 0):
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
offset = max(0, offset)
|
||||||
|
sql = "SELECT r.id, d.system, r.name, r.region, r.serial FROM releases r JOIN dats d ON d.id = r.dat_id"
|
||||||
|
where, params = [], []
|
||||||
|
if system:
|
||||||
|
where.append("d.system = ?")
|
||||||
|
params.append(system)
|
||||||
|
if q:
|
||||||
|
where.append("r.name LIKE ?")
|
||||||
|
params.append(f"%{q}%")
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
sql += " ORDER BY r.name LIMIT ? OFFSET ?"
|
||||||
|
params += [limit, offset]
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["cover"] = f"/api/cover/{d['id']}"
|
||||||
|
items.append(d)
|
||||||
|
return {"items": items, "limit": limit, "offset": offset}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/releases/{release_id}")
|
||||||
|
def releases_detail(release_id: int):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
rel = conn.execute(
|
||||||
|
"SELECT r.id, d.system, r.name, r.description, r.region, r.serial "
|
||||||
|
"FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?",
|
||||||
|
(release_id,),
|
||||||
|
).fetchone()
|
||||||
|
if rel is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
roms = conn.execute(
|
||||||
|
"SELECT name, size, crc32, md5, sha1, serial FROM roms WHERE release_id = ?", (release_id,)
|
||||||
|
).fetchall()
|
||||||
|
result = dict(rel)
|
||||||
|
result["cover"] = f"/api/cover/{release_id}"
|
||||||
|
result["roms"] = [dict(r) for r in roms]
|
||||||
|
result["meta"] = _metadata(conn, release_id, rel["system"])
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/releases/{release_id}")
|
||||||
|
def releases_update(release_id: int, payload: dict, user=Depends(require_role("admin", "editor"))):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
rel = conn.execute(
|
||||||
|
"SELECT r.id, d.system FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?",
|
||||||
|
(release_id,),
|
||||||
|
).fetchone()
|
||||||
|
if rel is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
# release-table columns
|
||||||
|
for col in ("name", "region", "serial", "description"):
|
||||||
|
if col in payload:
|
||||||
|
conn.execute(f"UPDATE releases SET {col} = ? WHERE id = ?", (payload[col], release_id))
|
||||||
|
# metadata (item_info)
|
||||||
|
meta_fields = {k: payload[k] for k in _META_FIELDS if k in payload}
|
||||||
|
if meta_fields:
|
||||||
|
_save_metadata(conn, release_id, rel["system"], meta_fields)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# covers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/cover/{release_id}")
|
||||||
|
def cover_get(release_id: int):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
system = _release_system(conn, release_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if system is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
path = _find_cover(release_id, system)
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="no cover available")
|
||||||
|
media = "image/jpeg" if path.suffix.lower() in (".jpg", ".jpeg") else "image/png"
|
||||||
|
if path.suffix.lower() == ".webp":
|
||||||
|
media = "image/webp"
|
||||||
|
return FileResponse(path, media_type=media)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cover/{release_id}")
|
||||||
|
async def cover_upload(release_id: int, file: UploadFile = File(...), user=Depends(require_role("admin", "editor"))):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
system = _release_system(conn, release_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if system is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) > 20 * (1 << 20):
|
||||||
|
raise HTTPException(status_code=400, detail="image too large")
|
||||||
|
_save_cover(release_id, system, data)
|
||||||
|
return {"ok": True, "url": f"/api/cover/{release_id}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/cover/{release_id}")
|
||||||
|
def cover_delete(release_id: int, user=Depends(require_role("admin", "editor"))):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
system = _release_system(conn, release_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if system is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
path = _find_cover(release_id, system)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# store
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/store")
|
||||||
|
def store_list(system: str = Query(default="games")):
|
||||||
|
root = _store_root(system)
|
||||||
|
if not root.is_dir():
|
||||||
|
return {"items": [], "count": 0, "system": system}
|
||||||
|
items = [
|
||||||
|
{"name": it["name"], "filename": Path(it["path"]).name, "size": it["size"], "mtime_ns": it["mtime_ns"], "system": system}
|
||||||
|
for it in store.scan(root)
|
||||||
|
]
|
||||||
|
return {"items": items, "count": len(items), "system": system}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/store/download/{system}/{name}")
|
||||||
|
def store_download(system: str, name: str, user=Depends(get_current_user)):
|
||||||
|
root = _store_root(system).resolve()
|
||||||
|
target = (root / name).resolve()
|
||||||
|
if not target.is_file() or root not in target.parents:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
_log_download(user, system, name, target.stat().st_size)
|
||||||
|
return FileResponse(target, filename=name, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/store/upload")
|
||||||
|
async def store_upload(system: str = Query(default="games"), file: UploadFile = File(...), user=Depends(require_role("admin", "editor"))):
|
||||||
|
root = _store_root(system)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe = _SAFE_NAME_RE.sub("", file.filename or "")
|
||||||
|
if not safe:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid filename")
|
||||||
|
dest = root / safe
|
||||||
|
data = await file.read()
|
||||||
|
dest.write_bytes(data)
|
||||||
|
return {"ok": True, "filename": safe, "size": len(data)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/store/{system}/{name}")
|
||||||
|
def store_delete(system: str, name: str, user=Depends(require_role("admin", "editor"))):
|
||||||
|
root = _store_root(system).resolve()
|
||||||
|
target = (root / name).resolve()
|
||||||
|
if not target.is_file() or root not in target.parents:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
target.unlink()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# stats (admin)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/stats")
|
||||||
|
def stats(user=Depends(require_role("admin"))):
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
systems = conn.execute(
|
||||||
|
"SELECT d.system AS system, COUNT(*) AS releases FROM releases r "
|
||||||
|
"JOIN dats d ON d.id = r.dat_id GROUP BY d.system ORDER BY d.system"
|
||||||
|
).fetchall()
|
||||||
|
users = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"]
|
||||||
|
covers_count = sum(1 for _ in config.COVERS_DIR.rglob("*") if _.is_file()) if config.COVERS_DIR.is_dir() else 0
|
||||||
|
downloads = conn.execute("SELECT COUNT(*) AS n FROM download_events").fetchone()["n"]
|
||||||
|
recent = conn.execute(
|
||||||
|
"SELECT e.system, e.name, e.bytes, e.created_at, u.username FROM download_events e "
|
||||||
|
"LEFT JOIN users u ON u.id = e.user_id ORDER BY e.id DESC LIMIT 20"
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"systems": [dict(r) for r in systems],
|
||||||
|
"users": users,
|
||||||
|
"covers": covers_count,
|
||||||
|
"downloads": downloads,
|
||||||
|
"recent": [dict(r) for r in recent],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Server-specific settings (env-driven)."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import COVERS_DIR, DB_PATH, STORE_ROOT
|
||||||
|
|
||||||
|
STORE_VIDEOS_ROOT = Path(os.environ.get("PSP_STORE_VIDEOS_DIR", "/data/store_videos"))
|
||||||
|
ADMIN_USERNAME = os.environ.get("PSP_ADMIN_USERNAME", "admin")
|
||||||
|
ADMIN_PASSWORD = os.environ.get("PSP_ADMIN_PASSWORD", "admin")
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""psppt server: FastAPI app assembly."""
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from app.db import init_db
|
||||||
|
|
||||||
|
from . import api, config, web
|
||||||
|
from .security import ensure_admin
|
||||||
|
|
||||||
|
log = logging.getLogger("psppt.server")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
init_db()
|
||||||
|
created = ensure_admin(config.ADMIN_USERNAME, config.ADMIN_PASSWORD)
|
||||||
|
if created:
|
||||||
|
log.warning(
|
||||||
|
"Created initial admin user %r with the configured password — change it now.",
|
||||||
|
config.ADMIN_USERNAME,
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="psppt server", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.include_router(api.router)
|
||||||
|
app.include_router(web.router)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Password hashing, API/session tokens, and FastAPI auth dependencies.
|
||||||
|
|
||||||
|
Stdlib-only crypto: PBKDF2-SHA256 for passwords, SHA-256 hashed bearer tokens
|
||||||
|
for both the desktop client (kind="api") and the WebUI session (kind="session").
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
|
||||||
|
_PBKDF2_ITERATIONS = 600_000
|
||||||
|
|
||||||
|
ADMIN_ROLE = "admin"
|
||||||
|
EDITOR_ROLE = "editor"
|
||||||
|
USER_ROLE = "user"
|
||||||
|
ROLES = (ADMIN_ROLE, EDITOR_ROLE, USER_ROLE)
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso():
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS)
|
||||||
|
return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
scheme, iterations, salt_hex, digest_hex = stored.split("$")
|
||||||
|
if scheme != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
salt = bytes.fromhex(salt_hex)
|
||||||
|
expected = bytes.fromhex(digest_hex)
|
||||||
|
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
||||||
|
return secrets.compare_digest(actual, expected)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(raw: str) -> str:
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def issue_token(user_id: int, kind: str = "api", label: str = "", expires_at: str = "") -> str:
|
||||||
|
"""Create a token row and return the raw token (only shown once)."""
|
||||||
|
raw = secrets.token_urlsafe(32)
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO api_tokens (user_id, token_hash, label, kind, created_at, last_used_at, expires_at, revoked_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, NULL)",
|
||||||
|
(user_id, _hash_token(raw), label, kind, now_iso(), now_iso(), expires_at or None),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_token(raw: str) -> None:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE api_tokens SET revoked_at = ? WHERE token_hash = ?",
|
||||||
|
(now_iso(), _hash_token(raw)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_token(raw: str):
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT u.id, u.username, u.role, u.is_active FROM api_tokens t "
|
||||||
|
"JOIN users u ON u.id = t.user_id "
|
||||||
|
"WHERE t.token_hash = ? AND t.revoked_at IS NULL "
|
||||||
|
"AND (t.expires_at IS NULL OR t.expires_at = '' OR t.expires_at > ?)",
|
||||||
|
(_hash_token(raw), now_iso()),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or not row["is_active"]:
|
||||||
|
return None
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ?",
|
||||||
|
(now_iso(), _hash_token(raw)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return dict(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request, authorization: str = Header(default="")):
|
||||||
|
"""Authenticate via ``Authorization: Bearer`` (client) or session cookie (WebUI)."""
|
||||||
|
token = ""
|
||||||
|
if authorization.startswith("Bearer "):
|
||||||
|
token = authorization[7:].strip()
|
||||||
|
else:
|
||||||
|
token = request.cookies.get("psppt_session", "")
|
||||||
|
user = get_user_by_token(token)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*roles):
|
||||||
|
"""Dependency factory: only users with one of ``roles`` may pass."""
|
||||||
|
def dependency(user=Depends(get_current_user)):
|
||||||
|
if user["role"] not in roles:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
return user
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_admin(username: str, password: str) -> bool:
|
||||||
|
"""Create the initial admin account when the users table is empty."""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
count = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"]
|
||||||
|
if count == 0:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, role, is_active, created_at) "
|
||||||
|
"VALUES (?, ?, 'admin', 1, ?)",
|
||||||
|
(username, hash_password(password), now_iso()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
background: #12131a; color: #e8e9ed; line-height: 1.45;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex; align-items: center; gap: 16px; padding: 10px 20px;
|
||||||
|
background: #1a1c26; border-bottom: 1px solid #2a2d3a;
|
||||||
|
}
|
||||||
|
nav a { color: #9fb4ff; text-decoration: none; font-weight: 600; }
|
||||||
|
nav a:hover { color: #c7d3ff; }
|
||||||
|
nav .spacer { flex: 1; }
|
||||||
|
nav .who { color: #8a8da0; font-size: 0.9em; }
|
||||||
|
main { max-width: 1100px; margin: 0 auto; padding: 24px 20px 60px; }
|
||||||
|
h1 { font-size: 1.6em; margin-top: 0; }
|
||||||
|
h2 { font-size: 1.15em; margin-top: 28px; color: #b9bdd0; }
|
||||||
|
.card { background: #1a1c26; border: 1px solid #2a2d3a; border-radius: 10px; padding: 24px; }
|
||||||
|
.card.narrow { max-width: 380px; margin: 60px auto; }
|
||||||
|
form label { display: block; margin: 10px 0 2px; font-size: 0.85em; color: #9aa0b5; }
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%; padding: 8px 10px; border-radius: 6px; border: 1px solid #34384a;
|
||||||
|
background: #0f1016; color: #e8e9ed; font: inherit;
|
||||||
|
}
|
||||||
|
form.inline { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
form.inline input, form.inline select { width: auto; }
|
||||||
|
form.filters input { width: 220px; }
|
||||||
|
form.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px; max-width: 760px; }
|
||||||
|
form.grid .wide { grid-column: 1 / -1; }
|
||||||
|
button {
|
||||||
|
padding: 8px 16px; border: 0; border-radius: 6px; cursor: pointer;
|
||||||
|
background: #3b5bdb; color: #fff; font: inherit; font-weight: 600;
|
||||||
|
}
|
||||||
|
button:hover { background: #4c6ef5; }
|
||||||
|
button.ghost { background: transparent; border: 1px solid #34384a; color: #c7cadb; }
|
||||||
|
button.danger { background: #b03a3a; }
|
||||||
|
button.danger:hover { background: #d14a4a; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.95em; }
|
||||||
|
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid #262936; }
|
||||||
|
th { color: #9aa0b5; font-weight: 600; }
|
||||||
|
tr:hover td { background: #171923; }
|
||||||
|
.cards { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||||
|
.stat { flex: 1; min-width: 120px; background: #1a1c26; border: 1px solid #2a2d3a;
|
||||||
|
border-radius: 10px; padding: 16px; text-align: center; }
|
||||||
|
.stat b { display: block; font-size: 1.7em; }
|
||||||
|
.stat span { color: #8a8da0; font-size: 0.85em; }
|
||||||
|
.error { color: #ff8787; }
|
||||||
|
.muted { color: #8a8da0; }
|
||||||
|
.cover { max-width: 200px; border-radius: 8px; border: 1px solid #2a2d3a; margin-bottom: 10px; }
|
||||||
|
code { background: #0f1016; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
label.check { display: flex; align-items: center; gap: 6px; margin: 0; }
|
||||||
|
label.check input { width: auto; }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}psppt server{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if user %}
|
||||||
|
<nav>
|
||||||
|
<a href="/dashboard">Dashboard</a>
|
||||||
|
<a href="/releases">Games & Videos</a>
|
||||||
|
{% if user.role in ('admin', 'editor') %}<a href="/store">Store</a>{% endif %}
|
||||||
|
{% if user.role == 'admin' %}<a href="/users">Users</a>{% endif %}
|
||||||
|
{% if user.role == 'admin' %}<a href="/settings">Settings</a>{% endif %}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="who">{{ user.username }} · {{ user.role }}</span>
|
||||||
|
<form method="post" action="/logout" class="inline"><button class="ghost">Logout</button></form>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard · psppt{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="stat"><b>{{ users }}</b><span>users</span></div>
|
||||||
|
<div class="stat"><b>{{ downloads }}</b><span>downloads</span></div>
|
||||||
|
<div class="stat"><b>{{ covers }}</b><span>covers</span></div>
|
||||||
|
<div class="stat"><b>{{ store_games }}</b><span>game archives</span></div>
|
||||||
|
<div class="stat"><b>{{ store_videos }}</b><span>video archives</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Catalog</h2>
|
||||||
|
<ul>
|
||||||
|
{% for s in systems %}
|
||||||
|
<li>{{ s.system }} — {{ s.releases }} release(s)</li>
|
||||||
|
{% else %}
|
||||||
|
<li>No catalog data ingested yet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Recent downloads</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>When</th><th>User</th><th>System</th><th>Name</th><th>Size</th></tr>
|
||||||
|
{% for r in recent %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.created_at }}</td>
|
||||||
|
<td>{{ r.username or '—' }}</td>
|
||||||
|
<td>{{ r.system }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td>{{ (r.bytes / 1048576) | round(1) }} MB</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5">No downloads yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Login · psppt{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="card narrow">
|
||||||
|
<h1>psppt server</h1>
|
||||||
|
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Username <input name="username" required autofocus></label>
|
||||||
|
<label>Password <input name="password" type="password" required></label>
|
||||||
|
<button>Log in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ rel.name }} · psppt{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/releases">← back</a></p>
|
||||||
|
<h1>{{ rel.name }}</h1>
|
||||||
|
<form method="post" action="/releases/{{ rel.id }}/edit" class="grid">
|
||||||
|
<label>Name <input name="name" value="{{ rel.name }}"></label>
|
||||||
|
<label>Region <input name="region" value="{{ rel.region or '' }}"></label>
|
||||||
|
<label>Serial <input name="serial" value="{{ rel.serial or '' }}"></label>
|
||||||
|
<label>Description <textarea name="description">{{ rel.description or '' }}</textarea></label>
|
||||||
|
<label>Title <input name="title" value="{{ meta.title or '' }}"></label>
|
||||||
|
<label>Release date <input name="release_date" value="{{ meta.release_date or '' }}"></label>
|
||||||
|
<label>Developer <input name="developer" value="{{ meta.developer or '' }}"></label>
|
||||||
|
<label>Publisher <input name="publisher" value="{{ meta.publisher or '' }}"></label>
|
||||||
|
<label>Genre <input name="genre" value="{{ meta.genre or '' }}"></label>
|
||||||
|
<label class="wide">Synopsis <textarea name="synopsis">{{ meta.synopsis or '' }}</textarea></label>
|
||||||
|
<button>Save</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Cover</h2>
|
||||||
|
{% if has_cover %}<img src="/api/cover/{{ rel.id }}" class="cover" alt="cover">{% else %}<p class="muted">No cover.</p>{% endif %}
|
||||||
|
<form method="post" action="/releases/{{ rel.id }}/cover" enctype="multipart/form-data" class="inline">
|
||||||
|
<input type="file" name="cover" accept="image/*">
|
||||||
|
<button>Upload cover</button>
|
||||||
|
</form>
|
||||||
|
{% if has_cover %}
|
||||||
|
<form method="post" action="/releases/{{ rel.id }}/cover/delete" class="inline">
|
||||||
|
<button class="danger">Delete cover</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Games & Videos · psppt{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Games & Videos</h1>
|
||||||
|
<form method="get" action="/releases" class="filters">
|
||||||
|
<select name="system">
|
||||||
|
<option value="">All systems</option>
|
||||||
|
<option value="games" {% if system == 'games' %}selected{% endif %}>Games</option>
|
||||||
|
<option value="videos" {% if system == 'videos' %}selected{% endif %}>Videos</option>
|
||||||
|
</select>
|
||||||
|
<input name="q" value="{{ q }}" placeholder="Search…">
|
||||||
|
<button>Filter</button>
|
||||||
|
</form>
|
||||||
|
<table>
|
||||||
|
<tr><th>Name</th><th>System</th><th>Region</th><th>Serial</th><th>Cover</th><th></th></tr>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ it.name }}</td>
|
||||||
|
<td>{{ it.system }}</td>
|
||||||
|
<td>{{ it.region or '' }}</td>
|
||||||
|
<td>{{ it.serial or '' }}</td>
|
||||||
|
<td>{% if it.has_cover %}✓{% else %}—{% endif %}</td>
|
||||||
|
<td><a href="/releases/{{ it.id }}/edit">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6">Nothing found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings · psppt{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<ul>
|
||||||
|
<li>Store root (games): <code>{{ store_root }}</code></li>
|
||||||
|
<li>Store root (videos): <code>{{ store_videos_root }}</code></li>
|
||||||
|
<li>Covers directory: <code>{{ covers_dir }}</code></li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">These paths are configured via environment variables — restart the server to change them.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Store · psppt{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Store</h1>
|
||||||
|
<h2>Upload archive</h2>
|
||||||
|
<form method="post" action="/store/upload" enctype="multipart/form-data" class="inline">
|
||||||
|
<select name="system">
|
||||||
|
<option value="games">Games</option>
|
||||||
|
<option value="videos">Videos</option>
|
||||||
|
</select>
|
||||||
|
<input type="file" name="file">
|
||||||
|
<button>Upload</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Games ({{ games | length }})</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Name</th><th>Size</th><th></th></tr>
|
||||||
|
{% for g in games %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ g.name }}</td>
|
||||||
|
<td>{{ (g.size / 1048576) | round(1) }} MB</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/store/games/{{ g.filename }}/delete" class="inline">
|
||||||
|
<button class="danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3">No game archives.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Videos ({{ videos | length }})</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Name</th><th>Size</th><th></th></tr>
|
||||||
|
{% for v in videos %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ v.name }}</td>
|
||||||
|
<td>{{ (v.size / 1048576) | round(1) }} MB</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/store/videos/{{ v.filename }}/delete" class="inline">
|
||||||
|
<button class="danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3">No video archives.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Users · psppt{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Users</h1>
|
||||||
|
<h2>Add user</h2>
|
||||||
|
<form method="post" action="/users" class="inline">
|
||||||
|
<input name="username" placeholder="username" required>
|
||||||
|
<input name="password" type="password" placeholder="password" required>
|
||||||
|
<select name="role">
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<button>Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Existing</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Username</th><th>Role</th><th>Active</th><th>Actions</th></tr>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.role }}</td>
|
||||||
|
<td>{{ 'yes' if u.is_active else 'no' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/users/{{ u.id }}" class="inline">
|
||||||
|
<select name="role">
|
||||||
|
<option value="user" {% if u.role == 'user' %}selected{% endif %}>user</option>
|
||||||
|
<option value="editor" {% if u.role == 'editor' %}selected{% endif %}>editor</option>
|
||||||
|
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
|
||||||
|
</select>
|
||||||
|
<input name="password" type="password" placeholder="new password">
|
||||||
|
<label class="check"><input type="checkbox" name="is_active" {% if u.is_active %}checked{% endif %}> active</label>
|
||||||
|
<button name="action" value="save">Save</button>
|
||||||
|
<button name="action" value="delete" class="danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
+339
@@ -0,0 +1,339 @@
|
|||||||
|
"""Server-rendered admin WebUI (Jinja2, plain HTML forms — no JS build)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app import store as store_mod
|
||||||
|
from app.db import get_connection
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .api import _find_cover, _metadata, _release_system, _store_root, _SAFE_NAME_RE
|
||||||
|
from .security import get_user_by_token, hash_password, issue_token, now_iso, require_role, revoke_token, verify_password
|
||||||
|
|
||||||
|
router = APIRouter(include_in_schema=False)
|
||||||
|
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request, name, **ctx):
|
||||||
|
ctx.setdefault("user", getattr(request.state, "user", None))
|
||||||
|
return templates.TemplateResponse(request, name, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def web_user(request: Request) -> dict:
|
||||||
|
user = get_user_by_token(request.cookies.get("psppt_session", ""))
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
|
||||||
|
request.state.user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def web_admin(request: Request, user=Depends(web_user)) -> dict:
|
||||||
|
if user["role"] != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def web_editor(request: Request, user=Depends(web_user)) -> dict:
|
||||||
|
if user["role"] not in ("admin", "editor"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Editor or admin required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# auth pages
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/login")
|
||||||
|
def login_page(request: Request):
|
||||||
|
return _render(request, "login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login_submit(request: Request):
|
||||||
|
form = await request.form()
|
||||||
|
username = (form.get("username") or "").strip()
|
||||||
|
password = form.get("password") or ""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, username, password_hash, role, is_active FROM users WHERE username = ?", (username,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if row is None or not row["is_active"] or not verify_password(password, row["password_hash"]):
|
||||||
|
return _render(request, "login.html", error="Invalid username or password")
|
||||||
|
token = issue_token(row["id"], kind="session", label="web session")
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute("UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), row["id"]))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
resp = RedirectResponse("/dashboard", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
resp.set_cookie("psppt_session", token, httponly=True, samesite="lax", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(request: Request):
|
||||||
|
token = request.cookies.get("psppt_session", "")
|
||||||
|
if token:
|
||||||
|
revoke_token(token)
|
||||||
|
resp = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
resp.delete_cookie("psppt_session", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# dashboard
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/")
|
||||||
|
def root():
|
||||||
|
return RedirectResponse("/dashboard", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def dashboard(request: Request, user=Depends(web_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
systems = conn.execute(
|
||||||
|
"SELECT d.system AS system, COUNT(*) AS releases FROM releases r "
|
||||||
|
"JOIN dats d ON d.id = r.dat_id GROUP BY d.system ORDER BY d.system"
|
||||||
|
).fetchall()
|
||||||
|
users = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"]
|
||||||
|
downloads = conn.execute("SELECT COUNT(*) AS n FROM download_events").fetchone()["n"]
|
||||||
|
covers = sum(1 for _ in config.COVERS_DIR.rglob("*") if _.is_file()) if config.COVERS_DIR.is_dir() else 0
|
||||||
|
recent = conn.execute(
|
||||||
|
"SELECT e.system, e.name, e.bytes, e.created_at, u.username FROM download_events e "
|
||||||
|
"LEFT JOIN users u ON u.id = e.user_id ORDER BY e.id DESC LIMIT 15"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
store_games = len(store_mod.scan(config.STORE_ROOT)) if config.STORE_ROOT.is_dir() else 0
|
||||||
|
store_videos = len(store_mod.scan(config.STORE_VIDEOS_ROOT)) if config.STORE_VIDEOS_ROOT.is_dir() else 0
|
||||||
|
return _render(request, "dashboard.html", systems=[dict(r) for r in systems], users=users,
|
||||||
|
downloads=downloads, covers=covers, recent=[dict(r) for r in recent],
|
||||||
|
store_games=store_games, store_videos=store_videos)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# releases (games / videos browser)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/releases")
|
||||||
|
def releases_page(request: Request, system: str = "", q: str = "", user=Depends(web_user)):
|
||||||
|
sql = "SELECT r.id, d.system, r.name, r.region, r.serial FROM releases r JOIN dats d ON d.id = r.dat_id"
|
||||||
|
where, params = [], []
|
||||||
|
if system:
|
||||||
|
where.append("d.system = ?")
|
||||||
|
params.append(system)
|
||||||
|
if q:
|
||||||
|
where.append("r.name LIKE ?")
|
||||||
|
params.append(f"%{q}%")
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
sql += " ORDER BY r.name LIMIT 200"
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["has_cover"] = _find_cover(r["id"], r["system"]).exists()
|
||||||
|
items.append(d)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return _render(request, "releases.html", items=items, system=system, q=q)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/releases/{release_id}/edit")
|
||||||
|
def release_edit_page(release_id: int, request: Request, user=Depends(web_editor)):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
rel = conn.execute(
|
||||||
|
"SELECT r.id, d.system, r.name, r.description, r.region, r.serial "
|
||||||
|
"FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?",
|
||||||
|
(release_id,),
|
||||||
|
).fetchone()
|
||||||
|
if rel is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
meta = _metadata(conn, release_id, rel["system"])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
has_cover = _find_cover(release_id, rel["system"]).exists()
|
||||||
|
return _render(request, "release_edit.html", rel=dict(rel), meta=meta, has_cover=has_cover)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/releases/{release_id}/edit")
|
||||||
|
async def release_edit_save(release_id: int, request: Request, user=Depends(web_editor)):
|
||||||
|
form = await request.form()
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
rel = conn.execute(
|
||||||
|
"SELECT d.system FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?", (release_id,)
|
||||||
|
).fetchone()
|
||||||
|
if rel is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE releases SET name = ?, region = ?, serial = ?, description = ? WHERE id = ?",
|
||||||
|
(form.get("name") or "", form.get("region") or "", form.get("serial") or "",
|
||||||
|
form.get("description") or "", release_id),
|
||||||
|
)
|
||||||
|
meta = {}
|
||||||
|
for k in ("title", "release_date", "developer", "publisher", "genre", "synopsis"):
|
||||||
|
v = form.get(k)
|
||||||
|
if v is not None and str(v).strip():
|
||||||
|
meta[k] = str(v).strip()
|
||||||
|
if meta:
|
||||||
|
from .api import _save_metadata
|
||||||
|
_save_metadata(conn, release_id, rel["system"], meta)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse(f"/releases/{release_id}/edit", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/releases/{release_id}/cover")
|
||||||
|
async def release_cover_upload(release_id: int, request: Request, user=Depends(web_editor)):
|
||||||
|
from .api import _save_cover
|
||||||
|
form = await request.form()
|
||||||
|
file = form.get("cover")
|
||||||
|
if file is None or not getattr(file, "filename", ""):
|
||||||
|
raise HTTPException(status_code=400, detail="no file")
|
||||||
|
data = await file.read()
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
system = _release_system(conn, release_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if system is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
_save_cover(release_id, system, data)
|
||||||
|
return RedirectResponse(f"/releases/{release_id}/edit", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/releases/{release_id}/cover/delete")
|
||||||
|
def release_cover_delete(release_id: int, user=Depends(web_editor)):
|
||||||
|
from .api import _find_cover
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
system = _release_system(conn, release_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if system:
|
||||||
|
p = _find_cover(release_id, system)
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
return RedirectResponse(f"/releases/{release_id}/edit", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# users (admin)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/users")
|
||||||
|
def users_page(request: Request, user=Depends(web_admin)):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, username, role, is_active, created_at, last_login_at FROM users ORDER BY username"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return _render(request, "users.html", users=[dict(r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def users_create(request: Request, user=Depends(web_admin)):
|
||||||
|
form = await request.form()
|
||||||
|
username = (form.get("username") or "").strip()
|
||||||
|
password = form.get("password") or ""
|
||||||
|
role = form.get("role") or "user"
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(status_code=400, detail="username and password required")
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, role, is_active, created_at) VALUES (?, ?, ?, 1, ?)",
|
||||||
|
(username, hash_password(password), role, now_iso()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=409, detail="username already exists")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}")
|
||||||
|
async def users_update(user_id: int, request: Request, user=Depends(web_admin)):
|
||||||
|
form = await request.form()
|
||||||
|
action = form.get("action") or ""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
if action == "delete":
|
||||||
|
if user_id != user["id"]:
|
||||||
|
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
else:
|
||||||
|
role = form.get("role")
|
||||||
|
if role in ("admin", "editor", "user"):
|
||||||
|
conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
|
||||||
|
conn.execute("UPDATE users SET is_active = ? WHERE id = ?", (1 if form.get("is_active") else 0, user_id))
|
||||||
|
pw = form.get("password") or ""
|
||||||
|
if pw:
|
||||||
|
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(pw), user_id))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# store management (editor+)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/store")
|
||||||
|
def store_page(request: Request, user=Depends(web_editor)):
|
||||||
|
def _rows(root):
|
||||||
|
out = []
|
||||||
|
for it in store_mod.scan(root):
|
||||||
|
out.append({"name": it["name"], "filename": Path(it["path"]).name, "size": it["size"]})
|
||||||
|
return out
|
||||||
|
games = _rows(config.STORE_ROOT) if config.STORE_ROOT.is_dir() else []
|
||||||
|
videos = _rows(config.STORE_VIDEOS_ROOT) if config.STORE_VIDEOS_ROOT.is_dir() else []
|
||||||
|
return _render(request, "store.html", games=games, videos=videos)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/store/upload")
|
||||||
|
async def store_upload(request: Request, user=Depends(web_editor)):
|
||||||
|
form = await request.form()
|
||||||
|
system = form.get("system") or "games"
|
||||||
|
file = form.get("file")
|
||||||
|
if file is None or not getattr(file, "filename", ""):
|
||||||
|
raise HTTPException(status_code=400, detail="no file")
|
||||||
|
data = await file.read()
|
||||||
|
root = _store_root(system)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe = _SAFE_NAME_RE.sub("", file.filename or "")
|
||||||
|
if not safe:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid filename")
|
||||||
|
(root / safe).write_bytes(data)
|
||||||
|
return RedirectResponse("/store", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/store/{system}/{name}/delete")
|
||||||
|
def store_delete(system: str, name: str, user=Depends(web_editor)):
|
||||||
|
root = _store_root(system).resolve()
|
||||||
|
target = (root / name).resolve()
|
||||||
|
if target.is_file() and root in target.parents:
|
||||||
|
target.unlink()
|
||||||
|
return RedirectResponse("/store", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# settings (admin)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/settings")
|
||||||
|
def settings_page(request: Request, user=Depends(web_admin)):
|
||||||
|
return _render(request, "settings.html",
|
||||||
|
store_root=config.STORE_ROOT, store_videos_root=config.STORE_VIDEOS_ROOT,
|
||||||
|
covers_dir=config.COVERS_DIR)
|
||||||
Reference in New Issue
Block a user