Compare commits

..
11 Commits
Author SHA1 Message Date
quoije 8b24d391ac Harden Library tab thread cleanup (covers + items)
build / Windows x64 (Wine) (push) Successful in 9m0s
build / Linux x86_64 (push) Successful in 10m44s
Library covers used the same QThread+worker architecture as the store but
never deleted finished threads/loaders, so every reload leaked one QThread
and one CoverLoader. Library item loading leaked its QThread too.

CoverShowcase now removes + deleteLater's loaders on their finished signal
and deletes their threads on thread.finished; MainWindow.load_library deletes
the finished worker and thread instead of only untracking them.
2026-09-14 20:53:30 -04:00
quoije d94ae52e14 Fix store crash when filtering after load (loader thread cleanup)
build / Windows x64 (Wine) (push) Successful in 10m49s
build / Linux x86_64 (push) Successful in 13m4s
StorePage cover/date loaders were never removed from tracking once their
thread finished, and _populate/_begin_scan dropped the whole list while
threads were still running. That garbage-collected QObjects whose QThread
had already been deleted, leaving a dangling thread affinity that crashed
Qt (SIGSEGV) when searching the store after it fully loaded.

Loaders now remove themselves and deleteLater on their finished signal, and
the tracking list is kept until each thread winds down.
2026-09-14 20:36:59 -04:00
quoije d8a552c61e Client: keep PS1/theme covers+metadata local when a server is configured
build / Linux x86_64 (push) Successful in 2m54s
build / Windows x64 (Wine) (push) Successful in 3m40s
2026-09-14 20:15:26 -04:00
quoije bf1fa04f11 Document the new FastAPI server and WebUI
build / Linux x86_64 (push) Successful in 2m55s
build / Windows x64 (Wine) (push) Successful in 3m50s
2026-09-14 19:41:35 -04:00
quoije 7b7d31bdfa Rewrite server: FastAPI + auth (users/roles/tokens) + admin WebUI + server-authoritative covers/metadata 2026-09-14 19:40:58 -04:00
quoije 14b0b955f5 Add All filter to NoPayStation store source
build / Linux x86_64 (push) Successful in 3m28s
build / Windows x64 (Wine) (push) Successful in 4m14s
2026-09-14 19:07:40 -04:00
quoije b378214e9b Run transfer queue jobs concurrently (max 3) so downloads don't block each other
build / Linux x86_64 (push) Successful in 3m15s
build / Windows x64 (Wine) (push) Successful in 3m41s
2026-09-14 12:50:05 -04:00
quoije 0110b50a07 Download cover bundle through the transfer queue (fix QProgressDialog NameError) 2026-09-14 12:47:13 -04:00
quoije 76eb6a6ef4 Smooth store source transitions: cancel stale batches and batch grid updates 2026-09-14 00:49:50 -04:00
quoije 58d348859a Fix store FD exhaustion: run cover/date loaders one at a time 2026-09-14 00:38:37 -04:00
quoije 3359ecf131 Fix store lag: decode cached covers off the GUI thread
build / Linux x86_64 (push) Successful in 2m48s
build / Windows x64 (Wine) (push) Successful in 3m17s
2026-09-14 00:24:48 -04:00
28 changed files with 1777 additions and 176 deletions
+4 -2
View File
@@ -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
+7 -3
View File
@@ -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"]
+48 -29
View File
@@ -404,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/psppt/ # 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)
@@ -460,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
+5
View File
@@ -86,6 +86,11 @@ COVERS_BUNDLE_URL = os.environ.get(
# 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", "")
+16 -1
View File
@@ -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")
@@ -1220,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)
+31
View File
@@ -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
);
""" """
+135 -93
View File
@@ -22,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")
@@ -463,7 +463,9 @@ class CoverShowcase(QWidget):
self.loader.loaded.connect(self._on_loaded) self.loader.loaded.connect(self._on_loaded)
self.loader.failed.connect(self._on_failed) self.loader.failed.connect(self._on_failed)
self.loader.finished.connect(self._on_loading_done) self.loader.finished.connect(self._on_loading_done)
self.loader.finished.connect(self._on_loader_finished)
self.loader.finished.connect(self.thread.quit) self.loader.finished.connect(self.thread.quit)
self.thread.finished.connect(self._on_thread_finished)
self._threads.append(self.thread) self._threads.append(self.thread)
self._loaders.append(self.loader) self._loaders.append(self.loader)
self.thread.start() self.thread.start()
@@ -521,6 +523,26 @@ class CoverShowcase(QWidget):
self._loading = False self._loading = False
self._update_status() self._update_status()
def _on_loader_finished(self):
"""Drop a finished cover loader from tracking and schedule its deletion."""
loader = self.sender()
if loader is None:
return
if loader in self._loaders:
self._loaders.remove(loader)
if self.loader is loader:
self.loader = None
loader.deleteLater()
def _on_thread_finished(self):
"""Delete a finished cover-loader thread."""
thread = self.sender()
if thread in self._threads:
self._threads.remove(thread)
if self.thread is thread:
self.thread = None
thread.deleteLater()
def _update_status(self): def _update_status(self):
if self._closing: if self._closing:
return return
@@ -1200,7 +1222,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)
@@ -1430,7 +1453,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()
@@ -1480,6 +1503,18 @@ 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()
# 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 = 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())
@@ -1831,6 +1866,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
@@ -1858,7 +1895,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)
@@ -1970,7 +2007,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)
@@ -2017,9 +2054,26 @@ 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.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
@@ -2037,7 +2091,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))
@@ -2080,6 +2134,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}")
@@ -2180,15 +2236,20 @@ 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))
# Cancel any in-flight cover loaders (stale generation). # Cancel any in-flight cover loaders (stale generation). Each loader
# removes itself from ``_loaders`` via its own ``finished`` handler once
# its thread winds down — dropping the list here would garbage-collect
# live QObjects and crash Qt.
for loader in self._loaders: for loader in self._loaders:
loader.cancel() loader.cancel()
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)
@@ -2231,10 +2292,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.
@@ -2293,6 +2354,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)
@@ -2305,13 +2367,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):
@@ -2342,13 +2405,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):
@@ -2359,12 +2423,23 @@ 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(self._on_loader_finished)
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 self._closing or gen != self._generation or not date: if self._closing or gen != self._generation or not date:
return return
@@ -2374,6 +2449,21 @@ class StorePage(QWidget):
if cell is not None: if cell is not None:
cell.setText(date) cell.setText(date)
def _on_loader_finished(self):
"""Drop a finished cover/date loader from tracking.
Loaders stay in ``_loaders`` until their thread finishes so their
QObject wrapper is never garbage-collected while its thread is still
winding down (that left a dangling thread affinity and crashed Qt on
filter/refresh).
"""
loader = self.sender()
if loader is None:
return
if loader in self._loaders:
self._loaders.remove(loader)
loader.deleteLater()
def _start_cover_loader(self, gen, tasks): def _start_cover_loader(self, gen, tasks):
if self._closing or not tasks: if self._closing or not tasks:
return return
@@ -2382,12 +2472,23 @@ class StorePage(QWidget):
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(self._on_loader_finished)
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_batch_done(self):
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): def _on_cover_loaded(self, gen, row, image):
if self._closing or gen != self._generation: if self._closing or gen != self._generation:
return return
@@ -2622,6 +2723,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):
@@ -3761,35 +3863,6 @@ class LibraryWorker(QObject):
self.finished.emit() self.finished.emit()
class CoversBundleWorker(QObject):
"""Download + extract the offline cover bundle off the GUI thread."""
progress = Signal(int, int) # done bytes, total bytes (0 = unknown)
succeeded = Signal()
failed = Signal(str)
finished = Signal()
def __init__(self):
super().__init__()
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
try:
covers.download_covers_bundle(
progress_cb=lambda d, t: self.progress.emit(d, t),
cancel_event=self._cancel)
except InterruptedError:
pass
except Exception as exc: # noqa: BLE001
self.failed.emit(str(exc))
else:
self.succeeded.emit()
finally:
self.finished.emit()
class SaveSyncPlanWorker(QObject): class SaveSyncPlanWorker(QObject):
"""Compute the save-sync plan off the GUI thread (content hashing is I/O-heavy).""" """Compute the save-sync plan off the GUI thread (content hashing is I/O-heavy)."""
plan = Signal(list, bool) # plan, switch_to_queue plan = Signal(list, bool) # plan, switch_to_queue
@@ -4739,6 +4812,8 @@ class MainWindow(QMainWindow):
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.result.connect(self._on_library_loaded) worker.result.connect(self._on_library_loaded)
worker.finished.connect(thread.quit) worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda t=thread, w=worker: self._forget_library_thread(t, w)) thread.finished.connect(lambda t=thread, w=worker: self._forget_library_thread(t, w))
self._library_threads.append(thread) self._library_threads.append(thread)
self._library_workers.append(worker) self._library_workers.append(worker)
@@ -4980,7 +5055,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:
@@ -5386,6 +5461,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)
@@ -5748,51 +5826,20 @@ class MainWindow(QMainWindow):
self._download_covers_bundle() self._download_covers_bundle()
def _download_covers_bundle(self): def _download_covers_bundle(self):
"""Download + extract the offline cover bundle with a progress dialog.""" """Queue the offline cover bundle download in the transfer queue."""
if self._closing or getattr(self, "_bundle_worker", None) is not None: if self._closing:
return return
prog = QProgressDialog("Downloading cover bundle…", "Cancel", 0, 0, self) for j in self.queue.jobs:
prog.setWindowTitle("Offline covers") if j.kind == "covers" and j.status in ("queued", "running", "cancelling"):
prog.setWindowModality(Qt.WindowModal) return # already queued or running
prog.setMinimumDuration(0) self.queue.enqueue(
prog.setAutoClose(False) transfers.CoversBundleSource(),
prog.setAutoReset(False) str(config.COVERS_DIR),
None,
result = {"ok": False, "err": ""} "Offline cover bundle",
thread = QThread(self) kind="covers",
worker = CoversBundleWorker() )
worker.moveToThread(thread) self.tabs.setCurrentWidget(self.queue_page)
thread.started.connect(worker.run)
worker.progress.connect(lambda d, t: self._on_bundle_progress(prog, d, t))
worker.succeeded.connect(lambda: result.update(ok=True))
worker.failed.connect(lambda e: result.update(err=str(e)))
prog.canceled.connect(worker.cancel)
worker.finished.connect(thread.quit)
thread.finished.connect(lambda: self._on_bundle_done(prog, result, worker, thread))
self._bundle_worker = worker
self._bundle_thread = thread
thread.start()
@staticmethod
def _on_bundle_progress(prog, done, total):
if total > 0:
prog.setRange(0, total)
prog.setValue(done)
prog.setLabelText(
f"Downloading cover bundle… {done / (1 << 20):.0f} / {total / (1 << 20):.0f} MB")
else:
prog.setRange(0, 0)
def _on_bundle_done(self, prog, result, worker, thread):
prog.close()
self._bundle_worker = None
self._bundle_thread = None
if result["ok"]:
QMessageBox.information(self, "Offline covers", "Cover bundle installed.")
covers.clear_miss_cache()
self.load_library()
elif result["err"]:
QMessageBox.warning(self, "Offline covers", f"Download failed:\n{result['err']}")
def closeEvent(self, event): def closeEvent(self, event):
self._closing = True self._closing = True
@@ -5802,11 +5849,6 @@ class MainWindow(QMainWindow):
self.worker.choose(None) self.worker.choose(None)
self.worker.cancel() self.worker.cancel()
self._thread_cleanup() self._thread_cleanup()
if getattr(self, "_bundle_worker", None) is not None:
self._bundle_worker.cancel()
if getattr(self, "_bundle_thread", None) is not None and _thread_running(self._bundle_thread):
self._bundle_thread.quit()
self._bundle_thread.wait()
self.cover_grid.shutdown() self.cover_grid.shutdown()
for t in self._meta_threads: for t in self._meta_threads:
if _thread_running(t): if _thread_running(t):
+16 -1
View File
@@ -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:
+35 -32
View File
@@ -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()
+112
View File
@@ -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 {}
+48 -7
View File
@@ -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,9 +306,10 @@ 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": "psppt/0.1"} headers = {"User-Agent": "psppt/0.1"}
@@ -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:
@@ -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:
+5 -4
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.110
uvicorn[standard]>=0.29
jinja2>=3.1
python-multipart>=0.0.9
+1
View File
@@ -0,0 +1 @@
"""psppt server package (FastAPI + admin WebUI)."""
+491
View File
@@ -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()
+9
View File
@@ -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")
+39
View File
@@ -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}
+138
View File
@@ -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
+52
View File
@@ -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; }
+26
View File
@@ -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 &amp; 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>
+37
View File
@@ -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 %}
+13
View File
@@ -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 %}
+31
View File
@@ -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 %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Games &amp; Videos · psppt{% endblock %}
{% block content %}
<h1>Games &amp; 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 %}
+11
View File
@@ -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 %}
+50
View File
@@ -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 %}
+41
View File
@@ -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
View File
@@ -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)