Initial commit
This commit is contained in:
+346
@@ -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())
|
||||
Reference in New Issue
Block a user