Compare commits

..
6 Commits
Author SHA1 Message Date
quoije 7381f2c39d Fix scrape crash + cover compose responsiveness
build / Windows x64 (Wine) (push) Successful in 6m16s
build / Linux x86_64 (push) Successful in 6m22s
- Cache the UMD/game cover templates AND their transparent-window rects
  (computed once, under a lock). The old code re-ran an O(w*h) Python pixel
  scan on every composited cover, stalling video cover loading and scraping
  for ~1s per cover with GIL contention.
- ScrapeDialog: ignore stale search/scrape results via a generation counter
  and cancel in-flight workers when a new search/scrape starts; clear threads
  on close.
- MetadataWorker (library details): cancel stale metadata fetches and delete
  finished workers/threads instead of leaking one per item selection.
2026-09-14 21:26:57 -04:00
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
27 changed files with 1747 additions and 103 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", "")
+37 -11
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")
@@ -773,11 +773,16 @@ def save_cover(release_id, system, data):
# --------------------------------------------------------------------------- #
# Game cover compositing (IGDB key art -> full PSP cover)
# --------------------------------------------------------------------------- #
_game_template_image = None
_game_template_cache = None # (template QImage or None, window (x, y, w, h))
_game_template_lock = threading.Lock()
def _transparent_rect(img):
"""Bounding box of the fully-transparent pixels in `img`."""
"""Bounding box of the fully-transparent pixels in `img`.
Run once per template (the result is cached) — an O(w*h) scan is far too
slow to repeat for every composed cover.
"""
minx, miny, maxx, maxy = img.width(), img.height(), -1, -1
for y in range(img.height()):
for x in range(img.width()):
@@ -790,13 +795,16 @@ def _transparent_rect(img):
def _game_template():
"""Load the PSP game cover template (top header + transparent window)."""
global _game_template_image
if _game_template_image is None:
"""Load the PSP game cover template; return ``(template, window)``."""
global _game_template_cache
if _game_template_cache is None:
with _game_template_lock:
if _game_template_cache is None:
try:
from PySide6.QtGui import QImage
except Exception: # noqa: BLE001 - headless API may lack PySide6
return None
_game_template_cache = (None, (0, 0, 0, 0))
return _game_template_cache
img = QImage()
for path in (
config.COVERS_DIR / "UMD_GAME_COVER_TEMPLATE.png",
@@ -806,8 +814,12 @@ def _game_template():
img.load(str(path))
if not img.isNull():
break
_game_template_image = img.convertToFormat(QImage.Format_ARGB32) if not img.isNull() else None
return _game_template_image
img = img.convertToFormat(QImage.Format_ARGB32) if not img.isNull() else None
_game_template_cache = (
img,
_transparent_rect(img) if img is not None and not img.isNull() else (0, 0, 0, 0),
)
return _game_template_cache
def compose_game_cover(key_art_bytes):
@@ -822,14 +834,13 @@ def compose_game_cover(key_art_bytes):
except Exception: # noqa: BLE001
return key_art_bytes
template = _game_template()
template, (x, y, w, h) = _game_template()
art = QImage.fromData(key_art_bytes)
if template is None or template.isNull() or art.isNull():
return key_art_bytes
art = art.convertToFormat(QImage.Format_ARGB32)
out = QImage(template.size(), QImage.Format_ARGB32)
out.fill(Qt.transparent)
x, y, w, h = _transparent_rect(template)
painter = QPainter(out)
if w > 0 and h > 0:
@@ -1220,6 +1231,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
);
"""
+140 -27
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")
@@ -165,12 +165,16 @@ class CoverLoader(QObject):
self.finished.emit()
_template_image = None
_template_cache = None # (template QImage, transparent window (x, y, w, h))
_template_lock = threading.Lock()
def _umd_template():
global _template_image
if _template_image is None:
"""Return ``(template QImage, transparent window rect)`` — loaded once."""
global _template_cache
if _template_cache is None:
with _template_lock:
if _template_cache is None:
img = QImage()
for path in (
config.COVERS_DIR / "UMD_COVER_TEMPLATE.png",
@@ -180,40 +184,46 @@ def _umd_template():
img.load(str(path))
if not img.isNull():
break
_template_image = img.convertToFormat(QImage.Format_ARGB32)
return _template_image
img = img.convertToFormat(QImage.Format_ARGB32)
_template_cache = (img, _transparent_window(img))
return _template_cache
def _transparent_window(img):
minx, miny, maxx, maxy = img.width(), img.height(), -1, -1
for y in range(img.height()):
for x in range(img.width()):
"""Bounding box of fully-transparent pixels as ``(x, y, w, h)``.
Run once per template (the result is cached), since this is an O(w*h)
scan that is far too slow to repeat per cover.
"""
w, h = img.width(), img.height()
minx, miny, maxx, maxy = w, h, -1, -1
for y in range(h):
for x in range(w):
if ((img.pixel(x, y) >> 24) & 0xFF) == 0:
minx, miny = min(minx, x), min(miny, y)
maxx, maxy = max(maxx, x), max(maxy, y)
if maxx < minx or maxy < miny:
return QRect()
return QRect(minx, miny, maxx - minx + 1, maxy - miny + 1)
return (0, 0, 0, 0)
return (minx, miny, maxx - minx + 1, maxy - miny + 1)
def compose_umd_cover(poster_bytes):
"""Paste a movie poster into the UMD template's transparent window (PNG bytes)."""
template = _umd_template()
template, (wx, wy, ww, wh) = _umd_template()
poster = QImage.fromData(poster_bytes)
if template.isNull() or poster.isNull():
return None
poster = poster.convertToFormat(QImage.Format_ARGB32)
out = QImage(template.size(), QImage.Format_ARGB32)
out.fill(Qt.transparent)
window = _transparent_window(template)
painter = QPainter(out)
if window.width() > 0 and window.height() > 0:
scaled = poster.scaled(window.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
ox = window.x() + (window.width() - scaled.width()) // 2
oy = window.y() + (window.height() - scaled.height()) // 2
if ww > 0 and wh > 0:
scaled = poster.scaled(QSize(ww, wh), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
ox = wx + (ww - scaled.width()) // 2
oy = wy + (wh - scaled.height()) // 2
painter.save()
painter.setClipRect(window)
painter.setClipRect(wx, wy, ww, wh)
painter.drawImage(ox, oy, scaled)
painter.restore()
painter.drawImage(0, 0, template)
@@ -463,7 +473,9 @@ class CoverShowcase(QWidget):
self.loader.loaded.connect(self._on_loaded)
self.loader.failed.connect(self._on_failed)
self.loader.finished.connect(self._on_loading_done)
self.loader.finished.connect(self._on_loader_finished)
self.loader.finished.connect(self.thread.quit)
self.thread.finished.connect(self._on_thread_finished)
self._threads.append(self.thread)
self._loaders.append(self.loader)
self.thread.start()
@@ -521,6 +533,26 @@ class CoverShowcase(QWidget):
self._loading = False
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):
if self._closing:
return
@@ -632,9 +664,16 @@ class MetadataWorker(QObject):
self.gen = gen
self.system = system
self.release_id = release_id
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
meta = info.get_or_fetch(self.release_id, self.system, self.title)
if self._cancel.is_set():
self.finished.emit()
return
self.result.emit(self.gen, meta or {})
self.finished.emit()
@@ -934,10 +973,16 @@ class ScrapeSearchWorker(QObject):
super().__init__()
self.system = system
self.query = query
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
found = covers.search_metadata_candidates(self.system, self.query, limit=20, timeout=10)
for r in found:
if self._cancel.is_set():
break
src = r.get("source")
if src == "openvgdb":
r["thumb_bytes"] = covers.fetch_openvgdb_local(r.get("title") or "")
@@ -949,6 +994,7 @@ class ScrapeSearchWorker(QObject):
else:
url = r.get("thumb_url")
r["thumb_bytes"] = covers.download_image(url, timeout=8) if url else None
if not self._cancel.is_set():
self.results.emit(found)
self.finished.emit()
@@ -962,10 +1008,17 @@ class ScrapeFetchWorker(QObject):
self.system = system
self.rid = rid
self.candidate = candidate
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
meta = covers.link_metadata(self.system, self.candidate, timeout=10) or {}
cover = _scrape_cover(self.system, self.candidate, self.rid, timeout=20)
if self._cancel.is_set():
self.finished.emit()
return
self.result.emit({"meta": meta, "cover": cover})
self.finished.emit()
@@ -1013,6 +1066,7 @@ class ScrapeDialog(QDialog):
self.resize(600, 580)
self.candidates = []
self.selected_meta = None
self._search_gen = 0
layout = QVBoxLayout(self)
top = QHBoxLayout()
@@ -1058,6 +1112,10 @@ class ScrapeDialog(QDialog):
query = self.query_edit.text().strip()
if not query:
return
self._search_gen += 1
gen = self._search_gen
for w in self._workers:
w.cancel()
self.status.setText("Searching…")
self.search_btn.setEnabled(False)
self.ok_btn.setEnabled(False)
@@ -1067,13 +1125,15 @@ class ScrapeDialog(QDialog):
worker = ScrapeSearchWorker(self.system, query)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.results.connect(self._on_results)
worker.results.connect(lambda r, g=gen: self._on_results(r, g))
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_results(self, results):
def _on_results(self, results, gen):
if gen != self._search_gen:
return
self.search_btn.setEnabled(True)
self.candidates = results
self.list.clear()
@@ -1104,6 +1164,10 @@ class ScrapeDialog(QDialog):
if item is None:
return
candidate = item.data(Qt.UserRole)
self._search_gen += 1
gen = self._search_gen
for w in self._workers:
w.cancel()
self.ok_btn.setEnabled(False)
self.search_btn.setEnabled(False)
self.status.setText("Scraping…")
@@ -1111,13 +1175,15 @@ class ScrapeDialog(QDialog):
worker = ScrapeFetchWorker(self.system, self.rid, candidate)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_scraped)
worker.result.connect(lambda p, g=gen: self._on_scraped(p, g))
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_scraped(self, payload):
def _on_scraped(self, payload, gen):
if gen != self._search_gen:
return
meta = payload.get("meta") or {}
cover = payload.get("cover")
if cover:
@@ -1133,11 +1199,14 @@ class ScrapeDialog(QDialog):
self.status.setText("Could not fetch anything for that selection.")
def closeEvent(self, event):
for w in self._workers:
w.cancel()
for t in self._threads:
if _thread_running(t):
t.quit()
t.wait()
self._workers = []
self._threads = []
event.accept()
@@ -1200,7 +1269,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 +1500,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 +1550,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())
@@ -2031,7 +2113,6 @@ class StorePage(QWidget):
self._cover_attempted.clear()
for loader in self._loaders:
loader.cancel()
self._loaders = []
self.grid.clear()
self.table.setRowCount(0)
self.status.setText("Loading…")
@@ -2207,10 +2288,12 @@ class StorePage(QWidget):
self._cover_attempted.clear()
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:
loader.cancel()
self._loaders = []
self.grid.clear()
self.grid.setUpdatesEnabled(False)
@@ -2388,6 +2471,7 @@ class StorePage(QWidget):
thread.started.connect(loader.run)
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)
thread.finished.connect(self._on_thread_finished)
self._threads.append(thread)
@@ -2412,6 +2496,21 @@ class StorePage(QWidget):
if cell is not None:
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):
if self._closing or not tasks:
return
@@ -2421,6 +2520,7 @@ class StorePage(QWidget):
thread.started.connect(loader.run)
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)
thread.finished.connect(self._on_thread_finished)
self._threads.append(thread)
@@ -4759,6 +4859,8 @@ class MainWindow(QMainWindow):
thread.started.connect(worker.run)
worker.result.connect(self._on_library_loaded)
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))
self._library_threads.append(thread)
self._library_workers.append(worker)
@@ -4899,6 +5001,8 @@ class MainWindow(QMainWindow):
return
self._meta_gen += 1
gen = self._meta_gen
for w in self._meta_workers:
w.cancel()
if item.get("title_override"):
title = item["title_override"]
elif system == "videos":
@@ -4910,11 +5014,20 @@ class MainWindow(QMainWindow):
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_metadata)
worker.finished.connect(worker.deleteLater)
worker.finished.connect(thread.quit)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda t=thread, w=worker: self._forget_meta_thread(t, w))
self._meta_threads.append(thread)
self._meta_workers.append(worker)
thread.start()
def _forget_meta_thread(self, thread, worker):
if thread in self._meta_threads:
self._meta_threads.remove(thread)
if worker in self._meta_workers:
self._meta_workers.remove(worker)
def _on_metadata(self, gen, meta):
if gen != self._meta_gen:
return
+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:
+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 {}
+9 -4
View File
@@ -306,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"}
@@ -319,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)
@@ -330,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:
+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)