Initial commit

This commit is contained in:
2026-09-06 22:30:13 -04:00
committed by quoije
commit 83627e788b
43 changed files with 43131 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
"""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/<id> single release + its rom hashes
GET /api/store list archives in the 7z store (token-protected)
GET /api/store/download/<name> 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 = "PSPPreserve/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": "PSP Preservation Tool 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())