347 lines
12 KiB
Python
347 lines
12 KiB
Python
"""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())
|