general store infra
This commit is contained in:
@@ -0,0 +1,912 @@
|
||||
#!/usr/bin/env python3
|
||||
"""store.py — WarpEngine catalog client for Batocera.
|
||||
|
||||
Reads a WarpEngine catalog API (`GET /api/software`), keeps the entries whose
|
||||
platform maps to a system this Batocera box actually has, downloads the matching
|
||||
release asset into that system's ROM folder and writes EmulationStation
|
||||
metadata (title, description, author, box art) into its gamelist.xml.
|
||||
|
||||
The engine knows nothing about any particular site: which host to talk to, what
|
||||
the store is called and where its games land all come from `config.json`. A
|
||||
store repository is therefore just that config plus an installer — see README.
|
||||
|
||||
Standard library only — Batocera ships python3 but no pip packages.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
VERSION = "2.0.0"
|
||||
STATE_VERSION = 2
|
||||
|
||||
# Every store keeps its code, config, state and log in one directory, so a box
|
||||
# can carry several stores side by side without them treading on each other.
|
||||
# The installer exports BATOCERA_STORE_HOME; run straight from a checkout the
|
||||
# script's own directory is the store home.
|
||||
DEFAULT_HOME = os.environ.get("BATOCERA_STORE_HOME") or os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"store": {
|
||||
# Short slug: names the state, the log prefix and the gamelist backup.
|
||||
"id": "warp",
|
||||
# Human-readable, used for the Ports entry and the ES folder label.
|
||||
"name": "WarpEngine Store",
|
||||
# The WarpEngine host serving the catalog.
|
||||
"base_url": "https://example.org",
|
||||
# Endpoint paths, in case the engine is mounted somewhere other than /.
|
||||
"api": {
|
||||
"catalog": "/api/software",
|
||||
"download": "/api/download",
|
||||
},
|
||||
},
|
||||
"paths": {
|
||||
"roms_root": "/userdata/roms",
|
||||
# Everything we install lives under this subfolder of the system's ROM
|
||||
# dir, so a prune can never touch ROMs the user put there themselves —
|
||||
# nor games installed by another store.
|
||||
"subfolder": "warp",
|
||||
},
|
||||
"emulationstation": {
|
||||
# Display name of our subfolder in ES; null hides the folder node.
|
||||
"folder_name": "WarpEngine Store",
|
||||
# Restart EmulationStation after a sync that changed something.
|
||||
"restart": True,
|
||||
},
|
||||
"catalog": {
|
||||
# Catalog `status` values worth installing. "development" is left out.
|
||||
"statuses": ["released", "archived"],
|
||||
# Restrict to one publisher (WarpEngine owner_id), null = whole catalog.
|
||||
"owner_id": None,
|
||||
# Software `name` allow/deny lists; empty allow list means "everything".
|
||||
"only": [],
|
||||
"exclude": [],
|
||||
},
|
||||
# WarpEngine platform -> Batocera system + which release asset to pull.
|
||||
"platforms": {
|
||||
"c64": {"system": "c64", "kind": "cartridge", "ext": ".prg", "enabled": True},
|
||||
"tic80": {"system": "tic80", "kind": "cartridge", "ext": ".tic", "enabled": True},
|
||||
},
|
||||
"behavior": {
|
||||
# Drop installed games that fell out of the catalog or the filters.
|
||||
"prune": True,
|
||||
"timeout": 30,
|
||||
"insecure": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# small helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
VERBOSE = False
|
||||
# Log prefix and temp-file prefix; replaced with the store id once configured.
|
||||
TAG = "batocera-store"
|
||||
|
||||
HOME = DEFAULT_HOME
|
||||
CONFIG_PATH = os.path.join(HOME, "config.json")
|
||||
STATE_PATH = os.path.join(HOME, "state.json")
|
||||
CATALOG_CACHE = os.path.join(HOME, "catalog.json")
|
||||
|
||||
|
||||
def set_home(config_path):
|
||||
"""Anchor state and cache next to the config file that selected this store."""
|
||||
global HOME, CONFIG_PATH, STATE_PATH, CATALOG_CACHE
|
||||
CONFIG_PATH = os.path.abspath(config_path)
|
||||
HOME = os.path.dirname(CONFIG_PATH)
|
||||
STATE_PATH = os.path.join(HOME, "state.json")
|
||||
CATALOG_CACHE = os.path.join(HOME, "catalog.json")
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[{TAG}] {msg}", flush=True)
|
||||
|
||||
|
||||
def debug(msg):
|
||||
if VERBOSE:
|
||||
log(msg)
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
print(f"[{TAG}] error: {msg}", file=sys.stderr, flush=True)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def load_json(path, default=None):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
except (OSError, ValueError) as exc:
|
||||
log(f"warning: cannot read {path}: {exc}")
|
||||
return default
|
||||
|
||||
|
||||
def write_json(path, data):
|
||||
write_atomic(path, json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
|
||||
def write_atomic(path, blob):
|
||||
"""Write bytes to `path` via a temp file in the same dir, then rename."""
|
||||
directory = os.path.dirname(path) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, prefix=f".{TAG}-", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(blob)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
return path
|
||||
|
||||
|
||||
def deep_merge(base, overlay):
|
||||
"""Recursively merge `overlay` into a copy of `base`; dicts merge, rest wins."""
|
||||
merged = dict(base)
|
||||
for key, value in (overlay or {}).items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def load_config(path, overrides=None):
|
||||
global TAG
|
||||
|
||||
user = load_json(path, default=None)
|
||||
if user is None:
|
||||
log(f"warning: no config at {path} — falling back to the built-in defaults")
|
||||
cfg = deep_merge(json.loads(json.dumps(DEFAULT_CONFIG)), user or {})
|
||||
|
||||
for key, value in (overrides or {}).items():
|
||||
if value is None:
|
||||
continue
|
||||
section, _, leaf = key.partition(".")
|
||||
cfg[section][leaf] = value
|
||||
|
||||
cfg["store"]["base_url"] = str(cfg["store"]["base_url"]).rstrip("/")
|
||||
if not cfg["store"].get("id"):
|
||||
die("config: store.id is required")
|
||||
if not cfg["paths"].get("subfolder"):
|
||||
die("config: paths.subfolder is required — it is what keeps stores apart")
|
||||
TAG = f"{cfg['store']['id']}-store"
|
||||
return cfg
|
||||
|
||||
|
||||
# Shorthands for the values the code below reaches for constantly.
|
||||
def base_url(cfg):
|
||||
return cfg["store"]["base_url"]
|
||||
|
||||
|
||||
def subfolder(cfg):
|
||||
return cfg["paths"]["subfolder"]
|
||||
|
||||
|
||||
def roms_root(cfg):
|
||||
return cfg["paths"]["roms_root"]
|
||||
|
||||
|
||||
def api_url(cfg, endpoint, params=None):
|
||||
path = cfg["store"]["api"][endpoint]
|
||||
url = base_url(cfg) + "/" + path.lstrip("/")
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
return url
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def user_agent(cfg):
|
||||
return f"batocera-store/{VERSION} ({cfg['store']['id']})"
|
||||
|
||||
|
||||
def _opener(cfg):
|
||||
if cfg["behavior"].get("insecure"):
|
||||
import ssl
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx))
|
||||
return urllib.request.build_opener()
|
||||
|
||||
|
||||
def http_get(cfg, url):
|
||||
"""Return (body_bytes, content_type)."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": user_agent(cfg)})
|
||||
with _opener(cfg).open(req, timeout=cfg["behavior"]["timeout"]) as resp:
|
||||
return resp.read(), resp.headers.get("Content-Type", "")
|
||||
|
||||
|
||||
def http_download(cfg, url, dest):
|
||||
"""Stream `url` into `dest` atomically. Returns bytes written."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": user_agent(cfg)})
|
||||
directory = os.path.dirname(dest) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, prefix=f".{TAG}-", suffix=".part")
|
||||
written = 0
|
||||
try:
|
||||
with _opener(cfg).open(req, timeout=cfg["behavior"]["timeout"]) as resp, os.fdopen(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = resp.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
written += len(chunk)
|
||||
if written == 0:
|
||||
raise IOError("empty response")
|
||||
os.replace(tmp, dest)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
return written
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# catalog
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def fetch_catalog(cfg, use_cache=False):
|
||||
owner = cfg["catalog"].get("owner_id")
|
||||
url = api_url(cfg, "catalog", {"owner_id": owner} if owner is not None else None)
|
||||
try:
|
||||
body, _ = http_get(cfg, url)
|
||||
data = json.loads(body.decode("utf-8"))
|
||||
write_json(CATALOG_CACHE, data)
|
||||
return data
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
cached = load_json(CATALOG_CACHE) if use_cache else None
|
||||
if cached is not None:
|
||||
log(f"warning: catalog fetch failed ({exc}) — using cached copy")
|
||||
return cached
|
||||
die(f"cannot fetch catalog from {url}: {exc}")
|
||||
|
||||
|
||||
def asset_basename(path):
|
||||
"""`/file/blessingofra-2.0.0.prg` -> `blessingofra-2.0.0.prg`."""
|
||||
return os.path.basename(path.rstrip("/"))
|
||||
|
||||
|
||||
def pick_release(entry, kind, ext):
|
||||
"""Newest non-dev release that carries the asset kind we need.
|
||||
|
||||
`releases` comes back from the API already sorted newest-first, and
|
||||
`latestRelease` is the newest stable one — check that first, then walk the
|
||||
rest so a game whose newest build is missing the asset still installs.
|
||||
"""
|
||||
candidates = []
|
||||
latest = entry.get("latestRelease")
|
||||
if latest:
|
||||
candidates.append(latest)
|
||||
candidates.extend(entry.get("releases") or [])
|
||||
|
||||
seen = set()
|
||||
for release in candidates:
|
||||
rid = release.get("id")
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
if str(release.get("version", "")).startswith("dev-"):
|
||||
continue
|
||||
for asset in release.get("assets") or []:
|
||||
if asset.get("kind") != kind:
|
||||
continue
|
||||
name = asset_basename(asset.get("path", ""))
|
||||
if not name or (ext and not name.lower().endswith(ext.lower())):
|
||||
continue
|
||||
return release, name
|
||||
return None, None
|
||||
|
||||
|
||||
def select_games(cfg, catalog, available_systems):
|
||||
"""Catalog -> list of installable game dicts, plus a list of skip reasons."""
|
||||
statuses = {s.lower() for s in cfg["catalog"].get("statuses") or []}
|
||||
only = {n.lower() for n in cfg["catalog"].get("only") or []}
|
||||
exclude = {n.lower() for n in cfg["catalog"].get("exclude") or []}
|
||||
|
||||
games, skipped = [], []
|
||||
for entry in catalog.get("softwares") or []:
|
||||
sw = entry.get("software") or {}
|
||||
name = sw.get("name")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
spec = cfg["platforms"].get(sw.get("platform"))
|
||||
if not spec or not spec.get("enabled", True):
|
||||
continue
|
||||
system = spec["system"]
|
||||
if system not in available_systems:
|
||||
skipped.append(f"{name}: no '{system}' ROM folder on this box")
|
||||
continue
|
||||
if statuses and str(sw.get("status", "")).lower() not in statuses:
|
||||
continue
|
||||
if only and name.lower() not in only:
|
||||
continue
|
||||
if name.lower() in exclude:
|
||||
continue
|
||||
|
||||
release, file_name = pick_release(entry, spec["kind"], spec.get("ext", ""))
|
||||
if not release:
|
||||
skipped.append(f"{name}: no '{spec['kind']}' asset in any release")
|
||||
continue
|
||||
|
||||
games.append(
|
||||
{
|
||||
"name": name,
|
||||
"system": system,
|
||||
"platform": sw.get("platform"),
|
||||
"version": str(release.get("version", "")),
|
||||
"asset": file_name,
|
||||
"title": sw.get("title") or name,
|
||||
"desc": (sw.get("desc") or "").strip(),
|
||||
"author": (sw.get("author") or "").strip(),
|
||||
"image_url": sw.get("imageUrl"),
|
||||
"releasedate": es_date(release.get("createdAt")),
|
||||
}
|
||||
)
|
||||
return games, skipped
|
||||
|
||||
|
||||
def es_date(iso):
|
||||
"""`2026-07-27T20:49:00.118Z` -> `20260727T204900` (ES gamelist format)."""
|
||||
if not iso or len(iso) < 19:
|
||||
return ""
|
||||
return f"{iso[0:4]}{iso[5:7]}{iso[8:10]}T{iso[11:13]}{iso[14:16]}{iso[17:19]}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# installation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def system_dir(cfg, system):
|
||||
return os.path.join(roms_root(cfg), system)
|
||||
|
||||
|
||||
def detect_systems(cfg):
|
||||
"""Systems this box has a ROM folder for — our compatibility check."""
|
||||
root = roms_root(cfg)
|
||||
if not os.path.isdir(root):
|
||||
return set()
|
||||
return {d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))}
|
||||
|
||||
|
||||
def rel_rom_path(cfg, game):
|
||||
return f"{subfolder(cfg)}/{game['asset']}"
|
||||
|
||||
|
||||
def rel_image_path(cfg, game, ext):
|
||||
return f"{subfolder(cfg)}/images/{game['name']}{ext}"
|
||||
|
||||
|
||||
def install_game(cfg, game, state_entry, dry_run=False):
|
||||
"""Download the asset + box art if missing. Returns (record, changed)."""
|
||||
base = system_dir(cfg, game["system"])
|
||||
rom_rel = rel_rom_path(cfg, game)
|
||||
rom_abs = os.path.join(base, rom_rel)
|
||||
changed = False
|
||||
|
||||
if os.path.isfile(rom_abs) and os.path.getsize(rom_abs) > 0:
|
||||
debug(f"{game['name']}: {game['asset']} already present")
|
||||
else:
|
||||
url = api_url(cfg, "download", {"path": game["asset"]})
|
||||
if dry_run:
|
||||
log(f"would download {game['name']} {game['version']} -> {rom_abs}")
|
||||
else:
|
||||
size = http_download(cfg, url, rom_abs)
|
||||
log(f"installed {game['name']} {game['version']} ({size} bytes) -> {rom_rel}")
|
||||
changed = True
|
||||
|
||||
image_rel = state_entry.get("image") if state_entry else None
|
||||
if game.get("image_url"):
|
||||
wanted = image_rel and os.path.isfile(os.path.join(base, image_rel))
|
||||
if not wanted:
|
||||
if dry_run:
|
||||
log(f"would fetch box art for {game['name']}")
|
||||
image_rel = rel_image_path(cfg, game, ".png")
|
||||
else:
|
||||
image_rel = fetch_image(cfg, game, base)
|
||||
changed = changed or bool(image_rel)
|
||||
else:
|
||||
# Box art disappeared from the catalog: drop the file we cached.
|
||||
if image_rel and not dry_run:
|
||||
stale = os.path.join(base, image_rel)
|
||||
if image_rel.startswith(subfolder(cfg) + "/") and os.path.isfile(stale):
|
||||
os.unlink(stale)
|
||||
changed = True
|
||||
image_rel = None
|
||||
|
||||
record = dict(game)
|
||||
record["rom"] = rom_rel
|
||||
record["image"] = image_rel
|
||||
return record, changed
|
||||
|
||||
|
||||
def image_url(cfg, game):
|
||||
"""The catalog gives a server-relative `imageUrl`; absolute ones pass through."""
|
||||
url = game["image_url"]
|
||||
return url if url.startswith(("http://", "https://")) else base_url(cfg) + url
|
||||
|
||||
|
||||
def fetch_image(cfg, game, base):
|
||||
try:
|
||||
body, ctype = http_get(cfg, image_url(cfg, game))
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
log(f"warning: box art for {game['name']} failed: {exc}")
|
||||
return None
|
||||
ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}.get(
|
||||
ctype.split(";")[0].strip().lower(), ".png"
|
||||
)
|
||||
rel = rel_image_path(cfg, game, ext)
|
||||
write_atomic(os.path.join(base, rel), body)
|
||||
debug(f"{game['name']}: box art -> {rel}")
|
||||
return rel
|
||||
|
||||
|
||||
def remove_game(cfg, record, dry_run=False):
|
||||
base = system_dir(cfg, record["system"])
|
||||
for rel in (record.get("rom"), record.get("image")):
|
||||
if not rel:
|
||||
continue
|
||||
path = os.path.join(base, rel)
|
||||
# Never step outside our own subfolder — another store may own it.
|
||||
if not rel.startswith(subfolder(cfg) + "/"):
|
||||
log(f"warning: refusing to delete {path} (outside {subfolder(cfg)}/)")
|
||||
continue
|
||||
if os.path.isfile(path):
|
||||
if dry_run:
|
||||
log(f"would remove {path}")
|
||||
else:
|
||||
os.unlink(path)
|
||||
debug(f"removed {path}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# gamelist.xml
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def gamelist_path(cfg, system):
|
||||
return os.path.join(system_dir(cfg, system), "gamelist.xml")
|
||||
|
||||
|
||||
def normalize_path(text):
|
||||
text = (text or "").strip()
|
||||
return text[2:] if text.startswith("./") else text
|
||||
|
||||
|
||||
def merge_gamelist(cfg, system, records, dry_run=False):
|
||||
"""Rewrite only the <game>/<folder> nodes under our subfolder.
|
||||
|
||||
Everything else in the file — the user's own scraped ROMs, their play
|
||||
counts, favourites, and whatever another store installed under its own
|
||||
subfolder — is parsed and written back untouched.
|
||||
"""
|
||||
path = gamelist_path(cfg, system)
|
||||
prefix = subfolder(cfg) + "/"
|
||||
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except ET.ParseError as exc:
|
||||
log(f"warning: {path} is not valid XML ({exc}) — starting a fresh gamelist")
|
||||
root = ET.Element("gameList")
|
||||
else:
|
||||
backup = f"{path}.{cfg['store']['id']}-backup"
|
||||
if not os.path.exists(backup) and not dry_run:
|
||||
shutil.copy2(path, backup)
|
||||
else:
|
||||
root = ET.Element("gameList")
|
||||
|
||||
for node in list(root):
|
||||
target = normalize_path(node.findtext("path"))
|
||||
if target.startswith(prefix) or target == subfolder(cfg):
|
||||
root.remove(node)
|
||||
|
||||
folder_name = cfg["emulationstation"].get("folder_name")
|
||||
if records and folder_name:
|
||||
folder = ET.SubElement(root, "folder")
|
||||
ET.SubElement(folder, "path").text = "./" + subfolder(cfg)
|
||||
ET.SubElement(folder, "name").text = folder_name
|
||||
|
||||
for record in sorted(records, key=lambda r: r["title"].lower()):
|
||||
game = ET.SubElement(root, "game")
|
||||
ET.SubElement(game, "path").text = "./" + record["rom"]
|
||||
ET.SubElement(game, "name").text = record["title"]
|
||||
if record.get("desc"):
|
||||
ET.SubElement(game, "desc").text = record["desc"]
|
||||
if record.get("image"):
|
||||
ET.SubElement(game, "image").text = "./" + record["image"]
|
||||
ET.SubElement(game, "thumbnail").text = "./" + record["image"]
|
||||
if record.get("author"):
|
||||
ET.SubElement(game, "developer").text = record["author"]
|
||||
ET.SubElement(game, "publisher").text = record["author"]
|
||||
if record.get("releasedate"):
|
||||
ET.SubElement(game, "releasedate").text = record["releasedate"]
|
||||
|
||||
indent(root)
|
||||
blob = b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="utf-8")
|
||||
if dry_run:
|
||||
log(f"would write {path} ({len(records)} entries)")
|
||||
return
|
||||
write_atomic(path, blob)
|
||||
log(f"gamelist updated: {path} ({len(records)} entries)")
|
||||
|
||||
|
||||
def indent(elem, level=0):
|
||||
pad = "\n" + " " * level
|
||||
if len(elem):
|
||||
if not (elem.text or "").strip():
|
||||
elem.text = pad + " "
|
||||
for child in elem:
|
||||
indent(child, level + 1)
|
||||
if not (child.tail or "").strip():
|
||||
child.tail = pad + " "
|
||||
if not (elem[-1].tail or "").strip():
|
||||
elem[-1].tail = pad
|
||||
if level and not (elem.tail or "").strip():
|
||||
elem.tail = pad
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# EmulationStation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def es_pid():
|
||||
"""PID of a running EmulationStation, or None."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["batocera-es-swissknife", "--espid"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
).stdout.strip()
|
||||
return int(out.split()[0]) if out.split() else None
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True # exists, just not ours to signal
|
||||
return True
|
||||
|
||||
|
||||
def wait_for_pid(pid, timeout=60):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if not pid_alive(pid):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def restart_es():
|
||||
if not shutil.which("batocera-es-swissknife"):
|
||||
log("batocera-es-swissknife not found — restart EmulationStation manually")
|
||||
return False
|
||||
log("restarting EmulationStation to pick up the new games")
|
||||
subprocess.Popen(["batocera-es-swissknife", "--restart"])
|
||||
return True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# state
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def game_key(record):
|
||||
"""Two systems may both carry a game called `foo` — key on both."""
|
||||
return f"{record['system']}:{record['name']}"
|
||||
|
||||
|
||||
def load_state():
|
||||
state = load_json(STATE_PATH, default=None) or {}
|
||||
version = state.get("version")
|
||||
if version == 1:
|
||||
state = migrate_state_v1(state)
|
||||
elif version != STATE_VERSION:
|
||||
state = {"version": STATE_VERSION, "installed": {}}
|
||||
state.setdefault("installed", {})
|
||||
return state
|
||||
|
||||
|
||||
def migrate_state_v1(state):
|
||||
"""v1 keyed `installed` by bare software name; v2 keys by `system:name`."""
|
||||
installed = {}
|
||||
for record in (state.get("installed") or {}).values():
|
||||
if record.get("system") and record.get("name"):
|
||||
installed[game_key(record)] = record
|
||||
log(f"migrated {len(installed)} state entries to the per-system key format")
|
||||
return {"version": STATE_VERSION, "installed": installed}
|
||||
|
||||
|
||||
def save_state(state):
|
||||
state["version"] = STATE_VERSION
|
||||
write_json(STATE_PATH, state)
|
||||
|
||||
|
||||
def records_by_system(installed):
|
||||
grouped = {}
|
||||
for record in installed.values():
|
||||
grouped.setdefault(record["system"], []).append(record)
|
||||
return grouped
|
||||
|
||||
|
||||
def match_keys(installed, names):
|
||||
"""Resolve user-typed `name` or `system:name` arguments to state keys."""
|
||||
wanted = {n.lower() for n in names}
|
||||
return [
|
||||
key for key, record in installed.items()
|
||||
if key.lower() in wanted or record["name"].lower() in wanted
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# commands
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def perform_sync(cfg, names=None, dry_run=False):
|
||||
"""Download every compatible game, prune what fell out of the catalog.
|
||||
|
||||
Returns (state, changed, touched_systems). Gamelists are left alone — the
|
||||
caller decides when it is safe to write them.
|
||||
"""
|
||||
systems = detect_systems(cfg)
|
||||
if not systems:
|
||||
# An unmounted or wrong ROMs root would otherwise look like "no system
|
||||
# is compatible any more" and prune everything we ever installed.
|
||||
die(f"no system folders under {roms_root(cfg)} — is it mounted?")
|
||||
|
||||
catalog = fetch_catalog(cfg, use_cache=True)
|
||||
games, skipped = select_games(cfg, catalog, systems)
|
||||
for reason in skipped:
|
||||
log(f"skipped {reason}")
|
||||
|
||||
if names:
|
||||
wanted = {n.lower() for n in names}
|
||||
games = [g for g in games if g["name"].lower() in wanted or game_key(g).lower() in wanted]
|
||||
|
||||
state = load_state()
|
||||
installed = state["installed"]
|
||||
touched, changed = set(), False
|
||||
|
||||
for game in games:
|
||||
key = game_key(game)
|
||||
previous = installed.get(key)
|
||||
if previous and previous.get("asset") != game["asset"]:
|
||||
log(f"{game['name']}: {previous.get('version')} -> {game['version']}, removing old files")
|
||||
remove_game(cfg, previous, dry_run=dry_run)
|
||||
previous = None
|
||||
changed = True
|
||||
try:
|
||||
record, did = install_game(cfg, game, previous, dry_run=dry_run)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
log(f"warning: {game['name']} failed: {exc}")
|
||||
continue
|
||||
if did or installed.get(key) != record:
|
||||
changed = True
|
||||
installed[key] = record
|
||||
touched.add(record["system"])
|
||||
|
||||
# A name-limited sync is not a full picture of the catalog, so never prune.
|
||||
if cfg["behavior"].get("prune") and not names:
|
||||
keep = {game_key(g) for g in games}
|
||||
for key in list(installed):
|
||||
if key in keep:
|
||||
continue
|
||||
log(f"pruning {key} (no longer in the catalog or filtered out)")
|
||||
remove_game(cfg, installed[key], dry_run=dry_run)
|
||||
touched.add(installed[key]["system"])
|
||||
del installed[key]
|
||||
changed = True
|
||||
|
||||
if not dry_run:
|
||||
save_state(state)
|
||||
return state, changed, touched
|
||||
|
||||
|
||||
def cmd_sync(cfg, args):
|
||||
state, changed, touched = perform_sync(cfg, args.name, dry_run=args.dry_run)
|
||||
if args.dry_run:
|
||||
log("dry run — nothing written")
|
||||
return 0
|
||||
if not changed and not args.force:
|
||||
log("already up to date")
|
||||
return 0
|
||||
|
||||
grouped = records_by_system(state["installed"])
|
||||
for system in sorted(touched | set(grouped)):
|
||||
merge_gamelist(cfg, system, grouped.get(system, []))
|
||||
|
||||
if cfg["emulationstation"].get("restart") and not args.no_restart and es_pid():
|
||||
restart_es()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sync_from_es(cfg, args):
|
||||
"""Sync as launched from the Ports menu — no console, so ES has to reload.
|
||||
|
||||
EmulationStation holds gamelists in memory and writes them back on exit,
|
||||
so downloading happens now and the gamelist merge is handed to a detached
|
||||
child that waits for the old ES process to die first.
|
||||
"""
|
||||
state, changed, _ = perform_sync(cfg)
|
||||
pid = es_pid()
|
||||
|
||||
if not changed:
|
||||
log("already up to date")
|
||||
if not pid or not changed:
|
||||
# Nothing racing us (or nothing new) — write the gamelists right here.
|
||||
if changed:
|
||||
for system, records in records_by_system(state["installed"]).items():
|
||||
merge_gamelist(cfg, system, records)
|
||||
return 0
|
||||
|
||||
subprocess.Popen(
|
||||
[sys.executable, os.path.abspath(__file__),
|
||||
"--config", CONFIG_PATH, "apply-gamelists", "--wait-pid", str(pid)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
|
||||
)
|
||||
if cfg["emulationstation"].get("restart") and not args.no_restart:
|
||||
restart_es()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_apply_gamelists(cfg, args):
|
||||
if args.wait_pid:
|
||||
if not wait_for_pid(args.wait_pid, timeout=90):
|
||||
log(f"warning: EmulationStation (pid {args.wait_pid}) still running — writing anyway")
|
||||
state = load_state()
|
||||
grouped = records_by_system(state["installed"])
|
||||
if not grouped:
|
||||
log("nothing installed")
|
||||
return 0
|
||||
for system, records in grouped.items():
|
||||
merge_gamelist(cfg, system, records, dry_run=args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(cfg, args):
|
||||
systems = detect_systems(cfg)
|
||||
catalog = fetch_catalog(cfg, use_cache=True)
|
||||
games, skipped = select_games(cfg, catalog, systems)
|
||||
installed = load_state()["installed"]
|
||||
|
||||
if not games:
|
||||
log("no compatible games in the catalog")
|
||||
for game in sorted(games, key=lambda g: (g["system"], g["title"].lower())):
|
||||
record = installed.get(game_key(game))
|
||||
if not record:
|
||||
mark = " "
|
||||
elif record.get("asset") == game["asset"]:
|
||||
mark = "* "
|
||||
else:
|
||||
mark = "^ "
|
||||
print(f"{mark}{game['system']:<8} {game['name']:<18} {game['version']:<10} {game['title']}")
|
||||
if games:
|
||||
print("\n * installed ^ update available")
|
||||
for reason in skipped:
|
||||
log(f"skipped {reason}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_remove(cfg, args):
|
||||
state = load_state()
|
||||
installed = state["installed"]
|
||||
touched = set()
|
||||
keys = match_keys(installed, args.name)
|
||||
if not keys:
|
||||
log(f"not installed: {', '.join(args.name)}")
|
||||
return 0
|
||||
for key in keys:
|
||||
record = installed[key]
|
||||
remove_game(cfg, record, dry_run=args.dry_run)
|
||||
touched.add(record["system"])
|
||||
if not args.dry_run:
|
||||
del installed[key]
|
||||
if args.dry_run:
|
||||
return 0
|
||||
save_state(state)
|
||||
grouped = records_by_system(installed)
|
||||
for system in touched:
|
||||
merge_gamelist(cfg, system, grouped.get(system, []))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_config(cfg, args):
|
||||
if args.write:
|
||||
if os.path.exists(args.config) and not args.force:
|
||||
die(f"{args.config} already exists (use --force to overwrite)")
|
||||
write_json(args.config, DEFAULT_CONFIG)
|
||||
log(f"wrote default config to {args.config}")
|
||||
return 0
|
||||
print(json.dumps(cfg, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
global VERBOSE
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=os.path.basename(sys.argv[0]) or "store.py",
|
||||
description="Install WarpEngine catalog releases into Batocera.",
|
||||
)
|
||||
parser.add_argument("--config", default=CONFIG_PATH,
|
||||
help=f"store config file (default: {CONFIG_PATH})")
|
||||
parser.add_argument("--base-url", help="override the catalog base URL")
|
||||
parser.add_argument("--roms-root", help="override the ROMs root (default: /userdata/roms)")
|
||||
parser.add_argument("-n", "--dry-run", action="store_true", help="report actions, change nothing")
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
parser.add_argument("--version", action="version", version=VERSION)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
p_sync = sub.add_parser("sync", help="download new/updated games and refresh gamelists")
|
||||
p_sync.add_argument("name", nargs="*", help="limit to these software names")
|
||||
p_sync.add_argument("--no-restart", action="store_true", help="never restart EmulationStation")
|
||||
p_sync.add_argument("--force", action="store_true", help="rewrite gamelists even without changes")
|
||||
p_sync.set_defaults(func=cmd_sync)
|
||||
|
||||
p_es = sub.add_parser("sync-from-es", help="sync when launched from the Ports menu")
|
||||
p_es.add_argument("--no-restart", action="store_true")
|
||||
p_es.set_defaults(func=cmd_sync_from_es)
|
||||
|
||||
p_apply = sub.add_parser("apply-gamelists", help="rewrite gamelists from the local state")
|
||||
p_apply.add_argument("--wait-pid", type=int, help="wait for this pid to exit first")
|
||||
p_apply.set_defaults(func=cmd_apply_gamelists)
|
||||
|
||||
p_list = sub.add_parser("list", help="show compatible catalog entries")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_rm = sub.add_parser("remove", help="uninstall games (name or system:name)")
|
||||
p_rm.add_argument("name", nargs="+")
|
||||
p_rm.set_defaults(func=cmd_remove)
|
||||
|
||||
p_cfg = sub.add_parser("config", help="print the effective config")
|
||||
p_cfg.add_argument("--write", action="store_true", help="write a template config file")
|
||||
p_cfg.add_argument("--force", action="store_true")
|
||||
p_cfg.set_defaults(func=cmd_config)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
VERBOSE = args.verbose
|
||||
set_home(args.config)
|
||||
cfg = load_config(args.config, {"store.base_url": args.base_url, "paths.roms_root": args.roms_root})
|
||||
os.makedirs(HOME, exist_ok=True)
|
||||
return args.func(cfg, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
Reference in New Issue
Block a user