Compare commits

...
8 Commits
Author SHA1 Message Date
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
28 changed files with 1729 additions and 170 deletions
+4 -2
View File
@@ -5,8 +5,10 @@ PSP_LAN_IP=192.168.10.100
# Host paths mounted into the container (Unraid shares).
PSP_STORE_HOST=/mnt/user/downloads/Sony - PlayStation Portable
PSP_STORE_VIDEOS_HOST=/mnt/user/psp/umd_videos_store
PSP_UMD_GAMES_HOST=/mnt/user/psp/umd_games
PSP_UMD_VIDEOS_HOST=/mnt/user/psp/umd_videos
# Secret token the desktop app sends as `Authorization: Bearer <token>`.
PSP_API_TOKEN=
# Initial admin account created on first boot (change the password after login).
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
# 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 server ./server
ENV PSP_DATA_DIR=/data \
PSP_DATS_DIR=/data/dats \
@@ -11,8 +14,9 @@ ENV PSP_DATA_DIR=/data \
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_STORE_VIDEOS_DIR=/data/store_videos
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
The catalog + store API is a single-file, stdlib-only HTTP server
(`app/api.py`) — no pip install needed on the server.
The server is a **FastAPI** app (`server/`) with an admin **WebUI** and a
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
PSP_DATA_DIR=/data \
PSP_DATS_DIR=/data/dats \
pip install -r requirements-server.txt
PSP_DB_PATH=/data/library.sqlite \
PSP_COVERS_DIR=/data/covers \
PSP_GAMES_ROOTS=/data/umd_games \
PSP_VIDEO_ROOTS=/data/umd_videos \
PSP_STORE_DIR=/data/store \
PSP_API_TOKEN=<long-random-secret> \
python3 -m app.api --host 0.0.0.0 --port 8000
PSP_STORE_VIDEOS_DIR=/data/store_videos \
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 |
|---|---|
| `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/<id>` | one release + its rom hashes |
| `GET /api/library?system=&refresh=` | your scanned library |
| `GET /api/cover/<id>` | cover image (fetched on demand) |
| `GET /api/covers/<name>.zip` | offline cover bundle (public) |
| `GET /api/store` | list store archives *(token)* |
| `GET /api/store/download/<name>` | stream one archive *(token)* |
| `GET /api/releases/<id>` | release detail + metadata + rom hashes |
| `PATCH /api/releases/<id>` | edit metadata *(editor+)* |
| `GET /api/cover/<id>` | cover image |
| `POST /api/cover/<id>` | upload/replace cover *(editor+)* |
| `GET /api/store?system=games|videos` | list store archives |
| `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
`Authorization: Bearer <token>`. Leave it unset to allow anonymous access
(not recommended).
Authentication is `Authorization: Bearer <token>` for the API and a session
cookie for the WebUI.
### Docker (Unraid)
`docker-compose.yml` builds the API 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):
`docker-compose.yml` builds the server image and attaches it to Unraid's `br0`
macvlan bridge so it gets its own LAN IP:
```bash
# 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
```
The compose file reads the LAN IP and Unraid share paths from `.env` and
mounts them into `/data/store`, `/data/umd_games` and `/data/umd_videos`
(the store is mounted read-only).
The compose file reads the LAN IP and Unraid share paths from `.env` and mounts
them into `/data/store`, `/data/store_videos`, `/data/umd_games` and
`/data/umd_videos` (the store mounts are writable so the WebUI can manage
archives).
### TLS (optional)
@@ -460,10 +476,13 @@ ports 80/443 on your router to it.
### Client
In the GUI: **Settings → Store** → enter the **Server URL** (e.g.
`http://<server-ip>:8000` or `https://psp.example.com`) and the same **API
token**, then pick **Server** in the Store tab's source selector. Use
**Archive.org** or **Local folder** the same way.
In the GUI: **Settings → Store** → enter the **Server URL**, **Server
username**, and **Server password**, then **Save settings** (the app logs in
and stores the per-user token). Pick **Server** in the Store tab's source
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
+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_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" /
# 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", "")
+16 -1
View File
@@ -19,7 +19,7 @@ import urllib.request
import zipfile
from pathlib import Path
from . import config, sources
from . import config, server_client, sources
from .db import get_connection
log = logging.getLogger("covers")
@@ -1220,6 +1220,21 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False):
except OSError:
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)
# In-memory + persistent negative caches: skip the network for recent misses.
last = _neg_cache.get(key)
+31
View File
@@ -115,6 +115,37 @@ CREATE TABLE IF NOT EXISTS ps1_entries (
error TEXT,
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
);
"""
+87 -87
View File
@@ -22,7 +22,7 @@ from PySide6.QtWidgets import (
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
log = logging.getLogger("gui")
@@ -1200,7 +1200,8 @@ class SettingsPage(QWidget):
# -- Store ----------------------------------------------------------- #
box, form = self._group("Store")
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_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)
@@ -1430,7 +1431,7 @@ class SettingsPage(QWidget):
return self.store_server_edit.text().strip()
def store_token(self):
return self.store_token_edit.text().strip()
return server_client.token()
def archive_item(self):
return self.archive_edit.text().strip()
@@ -1480,6 +1481,18 @@ class SettingsPage(QWidget):
def _save(self):
sources.save({key: edit.text() for key, edit in self.fields.items()})
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.setValue("auto_scan", self.auto_scan.isChecked())
s.setValue("scan_workers", self.workers_spin.value())
@@ -1831,6 +1844,8 @@ class StorePage(QWidget):
self._lib_cover = {} # norm -> (release_id, system)
self._psp_norm = set() # norm of library items installed on the PSP
self._cover_attempted = set()
self._cover_busy = False
self._date_busy = False
self._generation = 0
self._scan_gen = 0
self._meta_gen = 0
@@ -1858,7 +1873,7 @@ class StorePage(QWidget):
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
bar.addWidget(self.source_combo)
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.currentIndexChanged.connect(self._on_nps_type_changed)
bar.addWidget(self.nps_type)
@@ -1970,7 +1985,7 @@ class StorePage(QWidget):
if self.source_combo.currentIndex() == 1:
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)
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.LocalShareSource(self._store_dir)
@@ -2017,9 +2032,27 @@ class StorePage(QWidget):
if self._items:
self._apply_filter()
def _begin_scan(self):
"""Clear the stale view and invalidate in-flight cover/date batches."""
self._generation += 1
self._cover_busy = False
self._date_busy = False
self._cover_queue = []
self._cover_next = 0
self._date_queue = []
self._date_next = 0
self._cover_attempted.clear()
for loader in self._loaders:
loader.cancel()
self._loaders = []
self.grid.clear()
self.table.setRowCount(0)
self.status.setText("Loading…")
def refresh(self, force=False):
if self._closing:
return
self._begin_scan()
self._scan_gen += 1
gen = self._scan_gen
if self.source_combo.currentIndex() == 0: # Server
@@ -2037,7 +2070,7 @@ class StorePage(QWidget):
self.status.setText("Fetching store from archive.org…")
worker = RemoteStoreScanWorker(transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user))
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)
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…")
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, force=force))
@@ -2080,6 +2113,8 @@ class StorePage(QWidget):
self._items = []
self._filtered = []
self._dirty = False
self.grid.clear()
self.table.setRowCount(0)
log.warning("store unavailable: %s", message)
self.status.setText(f"Store unavailable: {message}")
@@ -2180,6 +2215,8 @@ class StorePage(QWidget):
self._cover_next = 0
self._date_queue = []
self._date_next = 0
self._cover_busy = False
self._date_busy = False
self._cover_attempted.clear()
self._placeholder = QIcon(_placeholder_pixmap(self.ICON_W, self.ICON_H))
@@ -2189,6 +2226,7 @@ class StorePage(QWidget):
self._loaders = []
self.grid.clear()
self.grid.setUpdatesEnabled(False)
self.table.setUpdatesEnabled(False)
self.table.setSortingEnabled(False)
self.table.setRowCount(show_count)
@@ -2293,6 +2331,7 @@ class StorePage(QWidget):
def _finish_fill(self):
if self._fill_timer is not None:
self._fill_timer.stop()
self.grid.setUpdatesEnabled(True)
self.table.setUpdatesEnabled(True)
self.table.setSortingEnabled(True)
total = len(self._filtered)
@@ -2305,13 +2344,14 @@ class StorePage(QWidget):
self._load_date_batch(self.DATE_BATCH)
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
end = min(self._cover_next + n, len(self._cover_queue))
if self._cover_next >= end:
return
tasks = self._cover_queue[self._cover_next:end]
self._cover_next = end
self._cover_busy = True
self._start_cover_loader(self._generation, tasks)
def _active_scrollbar(self):
@@ -2342,13 +2382,14 @@ class StorePage(QWidget):
self._fill_timer.start()
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
end = min(self._date_next + n, len(self._date_queue))
if self._date_next >= end:
return
tasks = self._date_queue[self._date_next:end]
self._date_next = end
self._date_busy = True
self._start_date_loader(self._generation, tasks)
def _start_date_loader(self, gen, tasks):
@@ -2359,12 +2400,22 @@ class StorePage(QWidget):
loader.moveToThread(thread)
thread.started.connect(loader.run)
loader.loaded.connect(self._on_date_loaded)
loader.finished.connect(self._on_date_batch_done)
loader.finished.connect(thread.quit)
thread.finished.connect(self._on_thread_finished)
self._threads.append(thread)
self._loaders.append(loader)
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):
if self._closing or gen != self._generation or not date:
return
@@ -2382,12 +2433,22 @@ class StorePage(QWidget):
loader.moveToThread(thread)
thread.started.connect(loader.run)
loader.loaded.connect(self._on_cover_loaded)
loader.finished.connect(self._on_cover_batch_done)
loader.finished.connect(thread.quit)
thread.finished.connect(self._on_thread_finished)
self._threads.append(thread)
self._loaders.append(loader)
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):
if self._closing or gen != self._generation:
return
@@ -2622,6 +2683,7 @@ class QueuePage(QWidget):
"umd-dump": "UMD dump",
"save-sync": "Save sync",
"nps-download": "NoPayStation",
"covers": "Covers bundle",
}
def __init__(self, parent=None):
@@ -3761,35 +3823,6 @@ class LibraryWorker(QObject):
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):
"""Compute the save-sync plan off the GUI thread (content hashing is I/O-heavy)."""
plan = Signal(list, bool) # plan, switch_to_queue
@@ -4980,7 +5013,7 @@ class MainWindow(QMainWindow):
if not self._nps_dir:
QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.")
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
kind = "nps-download"
else:
@@ -5386,6 +5419,9 @@ class MainWindow(QMainWindow):
QTimer.singleShot(0, self._refresh_psp)
if kind == "save-sync":
QTimer.singleShot(0, self._refresh_saves)
if kind == "covers":
covers.clear_miss_cache()
QTimer.singleShot(0, self.load_library)
if kind == "ps1-convert":
QTimer.singleShot(0, self.ps1_page.refresh)
QTimer.singleShot(0, self.load_library)
@@ -5748,51 +5784,20 @@ class MainWindow(QMainWindow):
self._download_covers_bundle()
def _download_covers_bundle(self):
"""Download + extract the offline cover bundle with a progress dialog."""
if self._closing or getattr(self, "_bundle_worker", None) is not None:
"""Queue the offline cover bundle download in the transfer queue."""
if self._closing:
return
prog = QProgressDialog("Downloading cover bundle…", "Cancel", 0, 0, self)
prog.setWindowTitle("Offline covers")
prog.setWindowModality(Qt.WindowModal)
prog.setMinimumDuration(0)
prog.setAutoClose(False)
prog.setAutoReset(False)
result = {"ok": False, "err": ""}
thread = QThread(self)
worker = CoversBundleWorker()
worker.moveToThread(thread)
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']}")
for j in self.queue.jobs:
if j.kind == "covers" and j.status in ("queued", "running", "cancelling"):
return # already queued or running
self.queue.enqueue(
transfers.CoversBundleSource(),
str(config.COVERS_DIR),
None,
"Offline cover bundle",
kind="covers",
)
self.tabs.setCurrentWidget(self.queue_page)
def closeEvent(self, event):
self._closing = True
@@ -5802,11 +5807,6 @@ class MainWindow(QMainWindow):
self.worker.choose(None)
self.worker.cancel()
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()
for t in self._meta_threads:
if _thread_running(t):
+16 -1
View File
@@ -12,7 +12,7 @@ import json
import sqlite3
import time
from . import config, covers
from . import config, covers, server_client
from .db import get_connection
_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)
if info is not None:
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)
last = _failed.get(key)
if last is not None and time.time() - last < _FAIL_TTL:
+39 -36
View File
@@ -79,13 +79,13 @@ class TransferQueue(QObject):
job_finished = Signal(dict)
job_removed = Signal(int)
def __init__(self, parent=None):
def __init__(self, parent=None, max_concurrent=3):
super().__init__(parent)
self.max_concurrent = max(1, int(max_concurrent))
self.jobs = []
self._pending = []
self._current = None
self._thread = None
self._worker = None
# job_id -> (QThread, JobWorker) for jobs currently running in parallel.
self._running = {}
# Keep strong references to every worker/thread for their full lifetime.
# Dropping a Python ref to a QObject that was moved to another thread
# deletes it directly and crashes (bus error).
@@ -115,8 +115,10 @@ class TransferQueue(QObject):
self.job_updated.emit(job.to_dict())
self.job_finished.emit(job.to_dict())
return
if job.status == "running" and self._worker is not None:
self._worker.cancel()
if job.status == "running":
pair = self._running.get(job_id)
if pair is not None:
pair[1].cancel()
job.status = "cancelling"
self.job_updated.emit(job.to_dict())
@@ -140,8 +142,8 @@ class TransferQueue(QObject):
self.remove(jid)
def shutdown(self):
if self._worker is not None:
self._worker.cancel()
for _t, worker in self._running.values():
worker.cancel()
for t in self._threads:
try:
if t.isRunning():
@@ -157,30 +159,29 @@ class TransferQueue(QObject):
return None
def _start_next(self):
if self._current is not None or not self._pending:
return
job = self._pending.pop(0)
self._current = job
job.status = "running"
log.info("starting job #%d: %s (%s)", job.id, job.title, job.kind)
self.job_updated.emit(job.to_dict())
while len(self._running) < self.max_concurrent and self._pending:
job = self._pending.pop(0)
job.status = "running"
log.info("starting job #%d: %s (%s)", job.id, job.title, job.kind)
self.job_updated.emit(job.to_dict())
self._thread = QThread(self)
self._worker = JobWorker(job.source, job.dest_dir, job)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.progress.connect(self._on_progress)
self._worker.finished.connect(self._on_finished)
self._worker.skipped.connect(self._on_skipped)
self._worker.cancelled.connect(self._on_cancelled)
self._worker.failed.connect(self._on_failed)
for sig in (self._worker.finished, self._worker.skipped,
self._worker.cancelled, self._worker.failed):
sig.connect(self._thread.quit)
self._thread.finished.connect(self._on_thread_finished)
self._threads.append(self._thread)
self._workers.append(self._worker)
self._thread.start()
thread = QThread(self)
worker = JobWorker(job.source, job.dest_dir, job)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.progress.connect(self._on_progress)
worker.finished.connect(self._on_finished)
worker.skipped.connect(self._on_skipped)
worker.cancelled.connect(self._on_cancelled)
worker.failed.connect(self._on_failed)
for sig in (worker.finished, worker.skipped,
worker.cancelled, worker.failed):
sig.connect(thread.quit)
thread.finished.connect(self._on_thread_finished)
self._running[job.id] = (thread, worker)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_progress(self, job_id, stage, fraction, detail):
job = self._find(job_id)
@@ -218,10 +219,12 @@ class TransferQueue(QObject):
self.job_finished.emit(job.to_dict())
def _on_thread_finished(self):
# Queued to the main thread once the worker thread has fully stopped,
# so it is now safe to drop the "current" pointers and advance.
self._current = None
self._thread = None
self._worker = None
# Queued to the main thread once a worker thread has fully stopped, so
# it is now safe to drop its pointers and advance the queue.
thread = self.sender()
for jid, pair in list(self._running.items()):
if pair[0] is thread:
del self._running[jid]
break
self._start_next()
+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
class CoversBundleSource:
"""Download the combined offline cover bundle (runs as a queue job)."""
label = "psppt-covers.zip"
def __init__(self, url=""):
self.url = url
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
from . import covers
def on_progress(done, total):
if progress_cb is None:
return
frac = (done / total) if total else 0.0
detail = (f"{done / (1 << 20):.0f} / {total / (1 << 20):.0f} MB"
if total else "")
progress_cb("Downloading", frac, detail)
try:
result = covers.download_covers_bundle(self.url,
progress_cb=on_progress,
cancel_event=cancel_event)
except InterruptedError:
raise TransferCancelled() from None
return str(result)
class FileCopySource(Source):
"""Copy a single local file (library -> PSP or PSP -> library).
@@ -279,9 +306,10 @@ class RemoteStoreSource(Source):
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.token = token or ""
self.system = system or "games"
def _headers(self):
headers = {"User-Agent": "psppt/0.1"}
@@ -292,10 +320,13 @@ class RemoteStoreSource(Source):
def scan(self, root=None):
if not self.base_url:
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:
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):
dest_dir = Path(dest_dir)
@@ -303,10 +334,11 @@ class RemoteStoreSource(Source):
# The listing's "name" has no extension; "filename" carries the real
# archive filename (e.g. "Game (USA).7z") used for the download URL.
name = item.get("filename") or item["name"]
system = item.get("system") or self.system
tmp = Path(tempfile.mkdtemp(prefix="psp-store-"))
archive = tmp / name
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)
iso_name = peek_archive(archive, cancel_event)
if not iso_name:
@@ -479,11 +511,19 @@ class NoPayStationSource(Source):
def __init__(self, content_type, tsv_url="", force=False):
from . import nopaystation
self.content_type = content_type
self.tsv_url = tsv_url or nopaystation.tsv_url(content_type)
self.tsv_url = tsv_url or ("" if content_type == "all" else nopaystation.tsv_url(content_type))
self.force = force
def scan(self, root=None):
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)
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.
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"
tmp = Path(tempfile.mkdtemp(prefix="nps-download-"))
pkg = tmp / f"{pkg_name}.pkg"
@@ -500,10 +541,10 @@ class NoPayStationSource(Source):
if progress_cb is not None:
progress_cb("Downloading", 0.0, "")
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None
kind_hint = item_ct if item_ct in ("theme", "dlc") else None
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir,
kind_hint, progress_cb, cancel_event)
if self.content_type in ("theme", "dlc"):
if item_ct in ("theme", "dlc"):
_write_sidecar(folder, item)
return folder
finally:
+5 -4
View File
@@ -15,13 +15,14 @@ services:
PSP_GAMES_ROOTS: /data/umd_games
PSP_VIDEO_ROOTS: /data/umd_videos
PSP_STORE_DIR: /data/store
# Secret token the desktop app sends as `Authorization: Bearer <token>`.
# Set in the .env file next to this compose file.
PSP_API_TOKEN: ${PSP_API_TOKEN:-}
PSP_STORE_VIDEOS_DIR: /data/store_videos
PSP_ADMIN_USERNAME: ${PSP_ADMIN_USERNAME:-admin}
PSP_ADMIN_PASSWORD: ${PSP_ADMIN_PASSWORD:-admin}
volumes:
- ./data:/data
# 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_VIDEOS_HOST}:/data/umd_videos:ro
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)