"""Zero-dependency HTTP API for browsing the PSP catalog. Run with:: python3 -m app.api [--host 0.0.0.0] [--port 8000] Endpoints: GET /api/summary counts per system (games/videos) GET /api/releases list; ?system=games&q=wipeout&limit=50&offset=0 GET /api/releases/ single release + its rom hashes GET /api/store list archives in the 7z store (token-protected) GET /api/store/download/ stream one 7z archive (token-protected) """ import argparse import json import os import re import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse from . import config, covers, pipeline, scanner, store from .db import get_connection, init_db ROOTS = [] _LIB_CACHE = {"ts": 0.0, "items": []} _LIB_LOCK = threading.Lock() _LIB_TTL = 60.0 # seconds a /api/library result stays fresh API_TOKEN = os.environ.get("PSP_API_TOKEN", "").strip() def _authorized(handler): """True when the request carries a valid token (or no token is required).""" if not API_TOKEN: return True auth = (handler.headers.get("Authorization") or "").strip() return auth in (f"Bearer {API_TOKEN}", API_TOKEN) def _json(handler, obj, status=200): body = json.dumps(obj, indent=2).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) def _qint(query, key, default): try: return int(query.get(key, [str(default)])[0]) except (ValueError, TypeError): return default def _cover_redirect(handler, conn, rid): row = conn.execute( "SELECT r.name, d.system FROM releases r JOIN dats d ON d.id = r.dat_id " "WHERE r.id = ?", (rid,) ).fetchone() if row is None: _json(handler, {"error": "not found"}, 404) return path = covers.fetch_cover(row["name"], rid, row["system"], timeout=15) if path is None or not path.exists(): _json(handler, {"error": "no cover available"}, 404) return body = path.read_bytes() ctype = "image/jpeg" if body.startswith(b"\xff\xd8\xff") else "image/png" handler.send_response(200) handler.send_header("Content-Type", ctype) handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) def _summary(conn): rows = conn.execute( "SELECT d.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() return {"systems": [dict(r) for r in rows]} def _releases(conn, system=None, q=None, limit=50, offset=0): 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] 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} def _release_detail(conn, rid): release = 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 = ?", (rid,), ).fetchone() if release is None: return None roms = conn.execute( "SELECT name, size, crc32, md5, sha1, serial FROM roms WHERE release_id = ?", (rid,), ).fetchall() result = dict(release) result["cover"] = f"/api/cover/{release['id']}" result["roms"] = [dict(r) for r in roms] return result def _store_listing(): items = [ { "name": it["name"], "filename": Path(it["path"]).name, "size": it["size"], "mtime_ns": it["mtime_ns"], } for it in store.scan(config.STORE_ROOT) ] return {"items": items, "count": len(items)} def _store_download(handler, name): root = Path(config.STORE_ROOT).resolve() target = (Path(config.STORE_ROOT) / name).resolve() if not target.is_file() or root not in target.parents: _json(handler, {"error": "not found"}, 404) return size = target.stat().st_size handler.send_response(200) handler.send_header("Content-Type", "application/octet-stream") handler.send_header("Content-Length", str(size)) handler.send_header("Content-Disposition", f'attachment; filename="{Path(name).name}"') handler.end_headers() with open(target, "rb") as f: while True: chunk = f.read(1 << 20) if not chunk: break handler.wfile.write(chunk) def _covers_download(handler, name): """Stream an offline cover bundle zip from ``data/covers/`` (public).""" root = (Path(config.DATA_DIR) / "covers").resolve() target = (root / name).resolve() if not target.is_file() or root not in target.parents: _json(handler, {"error": "not found"}, 404) return size = target.stat().st_size handler.send_response(200) handler.send_header("Content-Type", "application/zip") handler.send_header("Content-Length", str(size)) handler.send_header("Content-Disposition", f'attachment; filename="{name}"') handler.end_headers() with open(target, "rb") as f: while True: chunk = f.read(1 << 20) if not chunk: break handler.wfile.write(chunk) def _infer_system(path): p = (path or "").lower() if "umd_videos" in p: return "videos" if "umd_games" in p: return "games" return None def _refresh_library(conn): """Prune stale cache rows, then incrementally scan (only changed discs).""" stale = [ r["id"] for r in conn.execute("SELECT id, path FROM files").fetchall() if not os.path.exists(r["path"]) ] for fid in stale: conn.execute("DELETE FROM files WHERE id = ?", (fid,)) for _ in scanner.scan(ROOTS, conn=conn, hash_files=False): pass conn.commit() def _build_library(conn): items = [] seen = set() for row in conn.execute( "SELECT path, name, size, crc32, md5, sha1, serial, title FROM files ORDER BY name" ): if not os.path.exists(row["path"]): continue key = (row["name"], row["size"]) if key in seen: continue seen.add(key) record = { "path": row["path"], "name": row["name"], "size": row["size"], "serial": row["serial"], "title": row["title"], "crc32": row["crc32"], "md5": row["md5"], "sha1": row["sha1"], } ident = pipeline.identify(conn, record) release = ident["release"] kind = release["system"] if release else _infer_system(row["path"]) items.append({ "name": row["name"], "path": row["path"], "size": row["size"], "system": kind, "release_id": release["id"] if release else None, "serial": ident["serial"], "method": ident["method"], "canonical": ident["canonical"], "cover": f"/api/cover/{release['id']}" if release else None, }) return items def _library(conn, system=None, refresh=False): global _LIB_CACHE with _LIB_LOCK: if refresh: _refresh_library(conn) if refresh or time.time() - _LIB_CACHE["ts"] > _LIB_TTL: _LIB_CACHE = {"ts": time.time(), "items": _build_library(conn)} items = list(_LIB_CACHE["items"]) if system: items = [it for it in items if it["system"] == system] return {"items": items, "count": len(items)} class Handler(BaseHTTPRequestHandler): server_version = "psppt/0.1" def do_GET(self): parsed = urlparse(self.path) path = parsed.path.rstrip("/") or "/" query = parse_qs(parsed.query) conn = get_connection() try: if path == "/": _json(self, { "name": "psppt API", "endpoints": [ "/api/summary", "/api/releases?system=games|videos&q=&limit=&offset=", "/api/releases/{id}", "/api/library?system=games|videos", "/api/cover/{id}", "/api/store", "/api/store/download/{name}", ], }) elif path == "/api/summary": _json(self, _summary(conn)) elif path == "/api/releases": system = query.get("system", [None])[0] q = query.get("q", [None])[0] limit = min(_qint(query, "limit", 50), 500) offset = max(_qint(query, "offset", 0), 0) _json(self, _releases(conn, system, q, limit, offset)) elif path == "/api/library": system = query.get("system", [None])[0] refresh = query.get("refresh", ["0"])[0].lower() in ("1", "true", "yes") _json(self, _library(conn, system, refresh)) elif path == "/api/store": if not _authorized(self): _json(self, {"error": "unauthorized"}, 401) else: _json(self, _store_listing()) elif re.match(r"^/api/store/download/", path): if not _authorized(self): _json(self, {"error": "unauthorized"}, 401) else: _store_download(self, unquote(path.rsplit("/", 1)[1])) elif re.match(r"^/api/covers/[\w.\-]+\.zip$", path): _covers_download(self, unquote(path.rsplit("/", 1)[1])) elif re.match(r"^/api/cover/\d+$", path): rid = int(path.rsplit("/", 1)[1]) _cover_redirect(self, conn, rid) elif re.match(r"^/api/releases/\d+$", path): rid = int(path.rsplit("/", 1)[1]) detail = _release_detail(conn, rid) if detail is None: _json(self, {"error": "not found"}, 404) else: _json(self, detail) else: _json(self, {"error": "not found"}, 404) finally: conn.close() def log_message(self, fmt, *args): print("[api] " + fmt % args, flush=True) def main(argv=None): ap = argparse.ArgumentParser(description="Run the PSP catalog HTTP API") ap.add_argument("--host", default="0.0.0.0") ap.add_argument("--port", type=int, default=8000) ap.add_argument("--games-root", default=None, help="directory with game .iso files") ap.add_argument("--videos-root", default=None, help="directory with video .iso files") args = ap.parse_args(argv) global ROOTS ROOTS = [Path(p) for p in (args.games_root, args.videos_root) if p] if not ROOTS: ROOTS = config.GAMES_ROOTS + config.VIDEO_ROOTS conn = init_db() conn.close() server = ThreadingHTTPServer((args.host, args.port), Handler) print(f"PSP catalog API on http://{args.host}:{args.port} (roots: {[str(r) for r in ROOTS]})", flush=True) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() if __name__ == "__main__": raise SystemExit(main())