Rewrite server: FastAPI + auth (users/roles/tokens) + admin WebUI + server-authoritative covers/metadata
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user