Rewrite server: FastAPI + auth (users/roles/tokens) + admin WebUI + server-authoritative covers/metadata

This commit is contained in:
2026-09-14 19:40:58 -04:00
parent 14b0b955f5
commit 7b7d31bdfa
26 changed files with 1528 additions and 18 deletions
+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", "")
+14 -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,19 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False):
except OSError:
pass
# Server is authoritative for covers when configured — no libretro/IGDB
# fallback; if the server is unreachable the cover just stays empty.
if server_client.configured():
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
);
"""
+16 -3
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())
+14 -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,19 @@ 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 metadata when configured — no IGDB/TMDB
# fallback; an unreachable server just leaves metadata empty.
if server_client.configured():
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: