A RetroArch store engine for WarpEngine catalogs

The Batocera store reaches one kind of machine. RetroArch reads the same
cartridges on desktop Linux, Windows and macOS, on Android, on the Steam
Deck and on the console ports — the same seven titles, a far larger
audience — so this writes RetroArch's own library format instead:
one .lpl playlist per platform, with box art in the thumbnail folders that
playlist's name points at.

Three things it does differently, each because RetroArch is not one
machine:

- It reads retroarch.cfg for its directories. On the macOS install this
  was developed against, playlist_directory is ~/Documents/RetroArch/
  playlists while cores and thumbnails are under ~/Library/Application
  Support/RetroArch — an engine assuming <base>/playlists writes into the
  void. The leading ':' of a portable install is expanded too.
- It can write for a machine it is not running on (`export`). Android runs
  RetroArch but cannot run Python, so the only way to serve it is to
  render the tree here and copy it over; the same mechanism handles an SD
  card that will be mounted elsewhere.
- It refuses to write while RetroArch is running, because RetroArch holds
  its playlists in memory and writes them back on exit. The Batocera
  engine defers the write instead; here there is no Ports menu launching
  us, so refusing is both simpler and safer.

Decisions worth recording. One playlist per platform, because
default_core_path is a header field — that is what makes every entry start
without a core prompt; the entries themselves stay on DETECT so the file
survives being moved to a machine whose cores live elsewhere. A missing
core is a note, not an error, or the store would never run for anyone the
first time. The playlist name carries the store's name, so we never write
into RetroArch's own Commodore - 64.lpl and never collide with the
official thumbnail packs — and an entry the user added to our playlist
survives a rewrite. Thumbnails must be PNG and one of our covers is a
GIF, so the engine shells out to sips/magick/convert/ffmpeg, and simply
goes without the image if the machine has none of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:00:19 +02:00
co-authored by Claude Opus 5
commit e6a55f56a2
5 changed files with 1500 additions and 0 deletions
+977
View File
@@ -0,0 +1,977 @@
#!/usr/bin/env python3
"""retroarch_store.py — the RetroArch adapter of the WarpEngine store engine.
Reads a WarpEngine catalog (`GET /api/software`), downloads the cartridge assets
a libretro core can boot, and writes RetroArch's own library format: one `.lpl`
playlist per platform, with box art in the thumbnail folders that playlist's name
points at.
RetroArch is not one machine. The same playlist format is read on desktop Linux,
Windows, macOS, Android, the Steam Deck and the console ports — so the engine
never assumes where anything is: it reads `retroarch.cfg` for the four
directories it needs, and it can write a tree for a *different* machine than the
one it runs on (`export`), which is how Android is served at all.
Everything that is not specific to RetroArch — the catalog, release selection,
host matching, state, HTTP — lives in `warpstore.py`, the module this engine
shares with the other store engines. The installer puts the two side by side.
Standard library only.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import zlib
# The shared core sits next to this script (the installer puts it there); make
# sure that is where we look, however the script was invoked.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
import warpstore as ws
except ImportError:
sys.exit("error: warpstore.py is missing next to retroarch_store.py — reinstall the store "
"engine (https://git.teletypegames.org/engines/warpstore)")
from warpstore import debug, die, log
VERSION = "1.0.0"
# The playlist format version RetroArch writes today. Bump only after checking
# what a current RetroArch produces — the field is how it decides how to read
# the rest of the file.
PLAYLIST_VERSION = "1.5"
# Each store keeps its config, state, cache and log in one directory. The
# installer exports RETROARCH_STORE_HOME; run from a checkout, the script's own
# directory is the home.
DEFAULT_HOME = os.environ.get("RETROARCH_STORE_HOME") or os.path.dirname(os.path.abspath(__file__))
DEFAULT_CONFIG_PATH = os.path.join(DEFAULT_HOME, "config.json")
DEFAULT_CONFIG = {
"store": {
# Short slug: names the store home, the log prefix and the backups.
"id": "warp",
# Human-readable: goes into the playlist name, so it is what the user
# sees in RetroArch's main menu.
"name": "WarpEngine Store",
"base_url": "https://example.org",
"api": {
"catalog": "/api/software",
"download": "/api/download",
},
},
"paths": {
# All four null by default, meaning "work it out": the environment, then
# retroarch.cfg, then the per-OS candidates. Set one to pin it.
"retroarch_dir": None,
"playlists_dir": None,
"thumbnails_dir": None,
"libretro_dir": None,
# Where the cartridges land. Null = <retroarch_dir>/content.
"content_root": None,
# Our own folder inside it: what a prune is allowed to touch, and what
# keeps two stores on one machine out of each other's files.
"subfolder": "warp",
# Default for `export --target-prefix`: how the exported tree will be
# spelled on the machine that will actually run it.
"target_prefix": None,
},
"playlist": {
# `store` and `label` come from the config, `platform` from the catalog.
"name_template": "{store} - {label}",
# A real CRC32 helps RetroArch tie saves and thumbnails to content.
"write_crc32": True,
# One download, three folders: whichever thumbnail type the user has
# selected globally, there is an image for it. We do not touch their
# setting.
"thumbnail_kinds": ["Named_Boxarts", "Named_Titles", "Named_Snaps"],
},
"catalog": {
"statuses": ["released", "archived"],
"owner_id": None,
"only": [],
"exclude": [],
},
# WarpEngine platform -> playlist label, asset to pull, core that boots it.
"platforms": {
"c64": {
"label": "Commodore 64",
"kind": "cartridge",
"ext": ".prg",
"core": {"file": "vice_x64_libretro", "name": "VICE x64"},
"enabled": True,
},
"tic80": {
"label": "TIC-80",
"kind": "cartridge",
"ext": ".tic",
"core": {"file": "tic80_libretro", "name": "TIC-80"},
"enabled": True,
},
},
"behavior": {
"prune": True,
"timeout": 30,
"insecure": False,
# RetroArch writes its playlists back when it exits, so a sync while it
# runs can be undone. Refuse rather than lose the work.
"refuse_while_running": True,
# Box art must be PNG; convert with whatever the machine has if not.
"convert_images": True,
},
}
# What a libretro core file is called, per host. Android is the odd one out, and
# it is also the host we can only ever write for from somewhere else.
CORE_SUFFIX = {"darwin": ".dylib", "linux": ".so", "windows": ".dll", "android": "_android.so"}
# Where RetroArch keeps its configuration, in the order worth trying. Only used
# when nothing else says — retroarch.cfg itself is always preferred over these.
RETROARCH_DIRS = {
"darwin": ["~/Library/Application Support/RetroArch"],
"linux": [
"~/.config/retroarch",
"~/.var/app/org.libretro.RetroArch/config/retroarch", # flatpak (Steam Deck)
"~/snap/retroarch/current/.config/retroarch",
],
"windows": ["$APPDATA/RetroArch", "C:/RetroArch"],
"android": ["/storage/emulated/0/RetroArch", "/sdcard/RetroArch"],
}
# retroarch.cfg is not always in the RetroArch directory itself: a macOS install
# keeps it under config/.
CFG_NAMES = ["retroarch.cfg", "config/retroarch.cfg"]
# RetroArch replaces these characters when it turns a playlist label into a
# thumbnail file name, so we have to do the same or the image is never found.
THUMB_UNSAFE = '&*/:`<>?\\|'
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
# Tried in order; the first one the machine actually has wins.
CONVERTERS = [
("sips", ["sips", "-s", "format", "png", "{src}", "--out", "{dst}"]),
("magick", ["magick", "{src}", "{dst}"]),
("convert", ["convert", "{src}", "{dst}"]),
("ffmpeg", ["ffmpeg", "-y", "-loglevel", "error", "-i", "{src}", "{dst}"]),
]
def subfolder(cfg):
return cfg["paths"]["subfolder"]
# --------------------------------------------------------------------------
# retroarch.cfg
# --------------------------------------------------------------------------
def parse_retroarch_cfg(path):
"""RetroArch's config: `key = "value"` lines, `#` comments."""
values = {}
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip('"')
except OSError as exc:
debug(f"cannot read {path}: {exc}")
return values
def find_retroarch_cfg(base):
for name in CFG_NAMES:
path = os.path.join(base, name)
if os.path.isfile(path):
return path
return None
def resolve_ra_path(value, base):
"""Expand a directory as RetroArch spells it.
`~` is the home directory; a leading `:` means "relative to RetroArch's own
base directory", which is how a portable install refers to itself.
"""
value = (value or "").strip()
if not value:
return ""
if value.startswith(":"):
rest = value[1:].lstrip("\\/")
return os.path.abspath(os.path.join(base, rest)) if rest else os.path.abspath(base)
expanded = os.path.expanduser(os.path.expandvars(value))
return os.path.abspath(expanded) if os.path.isabs(expanded) else os.path.abspath(os.path.join(base, expanded))
def find_retroarch_dir(cfg, override=None):
"""RetroArch's directory: the flag, the config, the environment, then a guess."""
for value, source in ((override, "--retroarch-dir"),
(cfg["paths"].get("retroarch_dir"), "config"),
(os.environ.get("RETROARCH_DIR"), "RETROARCH_DIR")):
if value:
path = os.path.abspath(os.path.expanduser(os.path.expandvars(value)))
if not os.path.isdir(path):
die(f"{source} points at {path}, which is not a directory")
return path, source
candidates = RETROARCH_DIRS.get(ws.host_os(), [])
for candidate in candidates:
path = os.path.abspath(os.path.expanduser(os.path.expandvars(candidate)))
if os.path.isdir(path):
return path, "found"
die("cannot find RetroArch's directory. Tried:\n " + "\n ".join(candidates)
+ "\nPoint at it with --retroarch-dir, RETROARCH_DIR or paths.retroarch_dir.")
# --------------------------------------------------------------------------
# where we write, and how the target spells it
# --------------------------------------------------------------------------
def target_join(base, *parts):
"""Join a path the way the *target* machine writes them, not this one.
An export written on a Mac for an Android phone must contain POSIX paths;
only a Windows-looking base gets backslashes.
"""
sep = "\\" if ("\\" in base or re.match(r"^[A-Za-z]:", base)) else "/"
joined = base.rstrip("/\\")
for part in parts:
joined += sep + str(part).strip("/\\")
return joined
def target_within(path, root):
"""Containment test in *target* space, where os.sep may not apply."""
p = (path or "").replace("\\", "/")
r = (root or "").replace("\\", "/").rstrip("/")
return bool(r) and (p == r or p.startswith(r + "/"))
class Tree:
"""Where files go locally, and how the files we write name them.
For a local install the two coincide. For an export they do not: the content
lands in a staging directory here, while the playlists have to name it as it
will be on the machine that runs it. Keeping the two apart in one object is
what makes Android — where no Python can run — reachable at all.
"""
def __init__(self, playlists_dir, thumbnails_dir, content_root, target_content_root,
libretro_dir, target_libretro_dir, core_suffix, sources):
self.playlists_dir = playlists_dir
self.thumbnails_dir = thumbnails_dir
self.content_root = content_root
self.target_content_root = target_content_root
self.libretro_dir = libretro_dir
self.target_libretro_dir = target_libretro_dir
self.core_suffix = core_suffix
self.sources = sources
def content_dir(self, cfg, platform):
"""Local directory holding one platform's cartridges."""
return os.path.join(self.content_root, subfolder(cfg), platform)
def target_content_dir(self, cfg, platform):
return target_join(self.target_content_root, subfolder(cfg), platform)
def owned_root(self, cfg):
"""The only subtree this store may ever delete from."""
return os.path.join(self.content_root, subfolder(cfg))
def local_tree(cfg, args):
"""The tree of the RetroArch installed on this machine."""
base, base_source = find_retroarch_dir(cfg, getattr(args, "retroarch_dir", None))
cfg_path = find_retroarch_cfg(base)
racfg = parse_retroarch_cfg(cfg_path) if cfg_path else {}
sources = {"retroarch_dir": f"{base} ({base_source})",
"retroarch.cfg": cfg_path or "not found — using defaults"}
def pick(config_key, ra_key, default_name):
override = cfg["paths"].get(config_key)
if override:
path = os.path.abspath(os.path.expanduser(os.path.expandvars(override)))
sources[config_key] = f"{path} (config)"
return path
if racfg.get(ra_key):
path = resolve_ra_path(racfg[ra_key], base)
sources[config_key] = f"{path} (retroarch.cfg {ra_key})"
return path
path = os.path.join(base, default_name)
sources[config_key] = f"{path} (default)"
return path
playlists = pick("playlists_dir", "playlist_directory", "playlists")
thumbnails = pick("thumbnails_dir", "thumbnails_directory", "thumbnails")
libretro = pick("libretro_dir", "libretro_directory", "cores")
content = pick("content_root", "", "content")
suffix = getattr(args, "core_suffix", None) or CORE_SUFFIX.get(ws.host_os(), ".so")
sources["core_suffix"] = suffix
return Tree(playlists, thumbnails, content, content, libretro, None, suffix, sources)
def export_tree(cfg, args):
"""A canonical RetroArch-shaped tree for a machine that is not this one."""
root = os.path.abspath(os.path.expanduser(args.directory))
target = args.target_prefix or cfg["paths"].get("target_prefix") or root
suffix = args.core_suffix or CORE_SUFFIX.get(ws.host_os(), ".so")
sources = {
"export root": root,
"target prefix": target,
"core_suffix": suffix,
"target libretro dir": args.target_libretro_dir or "unknown — entries stay on DETECT",
}
return Tree(
playlists_dir=os.path.join(root, "playlists"),
thumbnails_dir=os.path.join(root, "thumbnails"),
content_root=os.path.join(root, "content"),
target_content_root=target_join(target, "content"),
libretro_dir=None,
target_libretro_dir=args.target_libretro_dir,
core_suffix=suffix,
sources=sources,
)
# --------------------------------------------------------------------------
# names
# --------------------------------------------------------------------------
def playlist_name(cfg, platform, spec):
"""What the user sees in RetroArch's menu, and the name everything hangs off.
It is also the `db_name` and the thumbnail folder, so it carries the store's
name: we never write into RetroArch's own `Commodore - 64.lpl`, and we never
collide with the official thumbnail packs.
"""
return cfg["playlist"]["name_template"].format(
store=cfg["store"]["name"], label=spec.get("label") or platform, platform=platform,
).strip()
def playlist_file_name(name):
return re.sub(r'[\\/:*?"<>|]', "_", name) + ".lpl"
def thumb_file_name(label):
return "".join("_" if c in THUMB_UNSAFE else c for c in label) + ".png"
# --------------------------------------------------------------------------
# catalog -> RetroArch
# --------------------------------------------------------------------------
def accept_for_retroarch(cfg):
"""The adapter's veto: a title is installable if a core is named for it.
The scope is the catalog platform, one per playlist — not the playlist name,
so renaming the store does not re-key everything that is installed.
"""
def accept(spec, sw):
platform = sw.get("platform")
if not (spec.get("core") or {}).get("file"):
raise ws.Skip(f"no core configured for {platform}")
return platform, {"playlist": playlist_name(cfg, platform, spec)}
return accept
def select_games(cfg, catalog, host_info=None):
return ws.select_games(cfg, catalog, accept_for_retroarch(cfg), host_info)
def resolve_core(cfg, tree, platform):
"""(core_path, core_name, note) for a platform's playlist header.
A missing core is a note, never an error: the playlist still works, the user
is asked which core to use once, and `Online Updater ▸ Core Downloader`
fixes it for good. A store that refuses to run without cores installed would
never run for anyone the first time.
"""
spec = cfg["platforms"][platform]
core = spec.get("core") or {}
name = core.get("name") or ""
file_name = (core.get("file") or "") + tree.core_suffix
if tree.target_libretro_dir:
# An export: we cannot check the other machine, so take the user's word.
return target_join(tree.target_libretro_dir, file_name), name, ""
if tree.libretro_dir:
path = os.path.join(tree.libretro_dir, file_name)
if os.path.isfile(path):
return path, name, ""
return "", name, (f"core not found: {file_name} — entries stay on DETECT "
f"(Online Updater ▸ Core Downloader ▸ {name})")
return "", name, f"core location unknown ({file_name}) — entries stay on DETECT"
# --------------------------------------------------------------------------
# box art
# --------------------------------------------------------------------------
def to_png(blob, ext, allow_convert=True):
"""RetroArch thumbnails must be PNG. Convert if the machine can, else None.
The catalog serves whatever was uploaded — today one cover is a GIF — and
the standard library cannot re-encode an image, so this leans on whatever
converter the host happens to have. Failing that, the entry simply has no
box art, which is far better than a broken sync.
"""
if blob[:8] == PNG_MAGIC:
return blob
if not allow_convert:
return None
for name, template in CONVERTERS:
if not shutil.which(name):
continue
tmp = tempfile.mkdtemp(prefix=f".{ws.TAG}-img-")
try:
src = os.path.join(tmp, "in" + (ext or ".img"))
dst = os.path.join(tmp, "out.png")
with open(src, "wb") as fh:
fh.write(blob)
cmd = [part.format(src=src, dst=dst) for part in template]
result = subprocess.run(cmd, capture_output=True, timeout=60)
if result.returncode == 0 and os.path.isfile(dst) and os.path.getsize(dst) > 0:
with open(dst, "rb") as fh:
converted = fh.read()
debug(f"converted box art to PNG with {name}")
return converted
debug(f"{name} could not convert the box art: {result.returncode}")
except (OSError, subprocess.SubprocessError) as exc:
debug(f"{name} failed: {exc}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
return None
def thumb_paths(cfg, tree, record):
"""The local thumbnail files for one entry, one per configured kind."""
playlist = record["playlist"]
name = thumb_file_name(record["title"])
return [os.path.join(tree.thumbnails_dir, playlist, kind, name)
for kind in cfg["playlist"]["thumbnail_kinds"]]
def install_thumbnails(cfg, tree, record, dry_run=False):
"""Fetch the cover once and put it in every thumbnail folder that wants it."""
wanted = thumb_paths(cfg, tree, record)
if all(os.path.isfile(p) and os.path.getsize(p) > 0 for p in wanted):
debug(f"{record['name']}: box art already in place")
return wanted, False
if dry_run:
log(f"would fetch box art for {record['name']} ({len(wanted)} folders)")
return wanted, True
blob, ext = ws.download_image(cfg, record)
if blob is None:
return [], False
png = to_png(blob, ext, allow_convert=cfg["behavior"].get("convert_images", True))
if png is None:
log(f"warning: {record['name']}: box art is {ext or 'not PNG'} and no converter "
f"(sips/magick/convert/ffmpeg) is available — skipping the thumbnail")
return [], False
first = None
for path in wanted:
if first is None:
ws.write_atomic(path, png)
first = path
continue
# Same bytes three times: link where the filesystem allows it.
os.makedirs(os.path.dirname(path), exist_ok=True)
if os.path.exists(path):
os.unlink(path)
try:
os.link(first, path)
except OSError:
shutil.copy2(first, path)
debug(f"{record['name']}: box art -> {len(wanted)} thumbnail folders")
return wanted, True
# --------------------------------------------------------------------------
# installation
# --------------------------------------------------------------------------
def crc32_of(path):
"""RetroArch stores the content's CRC32 as `XXXXXXXX|crc`."""
checksum = 0
with open(path, "rb") as fh:
while True:
chunk = fh.read(256 * 1024)
if not chunk:
break
checksum = zlib.crc32(chunk, checksum)
return format(checksum & 0xFFFFFFFF, "08X")
def install_game(cfg, tree, game, state_entry, dry_run=False):
"""Download the cartridge and its cover. Returns (record, changed)."""
platform = game["platform"]
local = os.path.join(tree.content_dir(cfg, platform), game["asset"])
changed = False
if os.path.isfile(local) and os.path.getsize(local) > 0:
debug(f"{game['name']}: {game['asset']} already present")
elif dry_run:
log(f"would download {game['name']} {game['version']} -> {local}")
changed = True
else:
size = ws.http_download(cfg, ws.download_url(cfg, game["asset"]), local)
log(f"installed {game['name']} {game['version']} ({size} bytes) -> {local}")
changed = True
record = dict(game)
record["content"] = local
record["target"] = target_join(tree.target_content_dir(cfg, platform), game["asset"])
record["crc32"] = ""
if cfg["playlist"].get("write_crc32") and os.path.isfile(local):
record["crc32"] = crc32_of(local)
thumbs = (state_entry or {}).get("thumbs") or []
if game.get("image_url"):
thumbs, did = install_thumbnails(cfg, tree, record, dry_run=dry_run)
changed = changed or did
elif thumbs and not dry_run:
# The cover disappeared from the catalog: drop what we cached.
remove_thumbnails(tree, thumbs, dry_run=dry_run)
thumbs, changed = [], True
record["thumbs"] = thumbs
return record, changed
def remove_thumbnails(tree, thumbs, dry_run=False):
for path in thumbs or []:
if not ws.within(path, tree.thumbnails_dir):
log(f"warning: refusing to delete {path} (outside {tree.thumbnails_dir})")
continue
if os.path.isfile(path):
if dry_run:
log(f"would remove {path}")
else:
os.unlink(path)
debug(f"removed {path}")
def remove_game(cfg, tree, record, dry_run=False):
"""Delete one entry's files — never anything outside what this store owns."""
content = record.get("content")
owned = tree.owned_root(cfg)
if content:
if not ws.within(content, owned):
log(f"warning: refusing to delete {content} (outside {owned})")
elif os.path.isfile(content):
if dry_run:
log(f"would remove {content}")
else:
os.unlink(content)
debug(f"removed {content}")
remove_thumbnails(tree, record.get("thumbs"), dry_run=dry_run)
# --------------------------------------------------------------------------
# the playlist
# --------------------------------------------------------------------------
def playlist_path(cfg, tree, platform):
spec = cfg["platforms"][platform]
return os.path.join(tree.playlists_dir, playlist_file_name(playlist_name(cfg, platform, spec)))
def read_playlist(path):
data = ws.load_json(path, default=None)
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
if os.path.isfile(path):
log(f"warning: {path} is not a playlist we can read — starting a fresh one")
return None
return data
def write_playlist(cfg, tree, platform, records, dry_run=False):
"""Write one platform's playlist, keeping anything the user added to it.
The file carries the store's name, so it is ours — but a user may still have
added an entry of their own to it, and that survives: only items pointing
inside this store's content folder are rewritten.
"""
spec = cfg["platforms"].get(platform)
if not spec:
debug(f"no platform config for {platform} — not writing a playlist")
return
path = playlist_path(cfg, tree, platform)
name = os.path.basename(path)
ours = tree.target_content_dir(cfg, platform)
existing = read_playlist(path)
foreign = [i for i in (existing or {}).get("items", [])
if not target_within(i.get("path"), ours)]
if existing and not dry_run:
backup = f"{path}.{cfg['store']['id']}-backup"
if not os.path.exists(backup):
os.makedirs(os.path.dirname(backup), exist_ok=True)
shutil.copy2(path, backup)
if not records and not foreign:
if os.path.isfile(path):
if dry_run:
log(f"would remove the now-empty playlist {path}")
else:
os.unlink(path)
log(f"removed the now-empty playlist {path}")
return
core_path, core_name, note = resolve_core(cfg, tree, platform)
if note:
log(f"{playlist_name(cfg, platform, spec)}: {note}")
items = list(foreign)
for record in sorted(records, key=lambda r: r["title"].lower()):
items.append({
"path": record["target"],
"entry_slot": -1,
"label": record["title"],
# The header's default core runs these; DETECT keeps the file
# portable to a machine whose cores live somewhere else.
"core_path": "DETECT",
"core_name": "DETECT",
"crc32": f"{record['crc32']}|crc" if record.get("crc32") else "",
"db_name": name,
})
playlist = {
"version": PLAYLIST_VERSION,
"default_core_path": core_path,
"default_core_name": core_name,
# Zero everywhere means "whatever the user has set globally" — a store
# has no business overriding how someone likes their library displayed.
"label_display_mode": 0,
"right_thumbnail_mode": 0,
"left_thumbnail_mode": 0,
"thumbnail_match_mode": 0,
"sort_mode": 0,
"items": items,
}
if dry_run:
log(f"would write {path} ({len(records)} of ours, {len(foreign)} kept)")
return
ws.write_json(path, playlist)
log(f"playlist updated: {path} ({len(records)} entries" +
(f", {len(foreign)} kept" if foreign else "") + ")")
# --------------------------------------------------------------------------
# RetroArch itself
# --------------------------------------------------------------------------
def retroarch_running():
"""True if a RetroArch process is up — it would write our playlist back."""
if ws.host_os() == "windows":
try:
out = subprocess.run(["tasklist"], capture_output=True, text=True, timeout=15).stdout
return "retroarch.exe" in out.lower()
except (OSError, subprocess.SubprocessError):
debug("cannot tell whether RetroArch is running")
return False
if not shutil.which("pgrep"):
debug("no pgrep — cannot tell whether RetroArch is running")
return False
for name in ("retroarch", "RetroArch", "org.libretro.RetroArch"):
try:
if subprocess.run(["pgrep", "-x", name], capture_output=True, timeout=15).returncode == 0:
return True
except (OSError, subprocess.SubprocessError):
debug("cannot tell whether RetroArch is running")
return False
return False
def guard_running(cfg, args):
if not cfg["behavior"].get("refuse_while_running") or getattr(args, "force", False):
return
if retroarch_running():
die("RetroArch is running: it holds its playlists in memory and writes them back on "
"exit, so a sync now can be undone. Close it and try again (or --force).")
# --------------------------------------------------------------------------
# commands
# --------------------------------------------------------------------------
def install_all(cfg, tree, games, installed, dry_run=False, prune=False):
"""Install every game, prune what left the catalog. Returns (changed, scopes)."""
touched, changed = set(), False
for game in games:
key = ws.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, tree, previous, dry_run=dry_run)
previous = None
changed = True
try:
record, did = install_game(cfg, tree, 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["scope"])
if prune:
keep = {ws.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, tree, installed[key], dry_run=dry_run)
touched.add(ws.record_scope(installed[key]))
del installed[key]
changed = True
return changed, touched
def cmd_sync(cfg, args):
guard_running(cfg, args)
tree = local_tree(cfg, args)
catalog = ws.fetch_catalog(cfg, use_cache=True)
games, skipped = select_games(cfg, catalog)
for reason in skipped:
log(f"skipped {reason}")
if args.name:
games = ws.limit_to_names(games, args.name)
state = ws.load_state()
installed = state["installed"]
# A name-limited sync is not a full picture of the catalog, so never prune.
changed, touched = install_all(cfg, tree, games, installed, dry_run=args.dry_run,
prune=cfg["behavior"].get("prune") and not args.name)
if args.dry_run:
grouped = ws.records_by_scope(installed)
for platform in sorted(touched | set(grouped)):
write_playlist(cfg, tree, platform, grouped.get(platform, []), dry_run=True)
log("dry run — nothing written")
return 0
ws.save_state(state)
if not changed and not args.force:
log("already up to date")
return 0
grouped = ws.records_by_scope(installed)
for platform in sorted(touched | set(grouped)):
write_playlist(cfg, tree, platform, grouped.get(platform, []))
log("restart RetroArch to see the playlist — it builds the menu at startup")
return 0
def cmd_export(cfg, args):
"""Render the whole store into a tree meant for another machine.
Stateless on purpose: an export is a complete picture of the catalog, not an
incremental update to this machine's installation, and it must not disturb
the state of a local install living in the same store home.
"""
tree = export_tree(cfg, args)
catalog = ws.fetch_catalog(cfg, use_cache=True)
games, skipped = select_games(cfg, catalog)
for reason in skipped:
log(f"skipped {reason}")
if args.name:
games = ws.limit_to_names(games, args.name)
log(f"exporting {len(games)} titles to {tree.sources['export root']}")
log(f"playlists will point at {tree.target_content_root}")
records = {}
install_all(cfg, tree, games, records, dry_run=args.dry_run)
grouped = ws.records_by_scope(records)
for platform, group in sorted(grouped.items()):
write_playlist(cfg, tree, platform, group, dry_run=args.dry_run)
if not args.dry_run:
log("copy the tree onto the device, e.g.: adb push "
f"{os.path.abspath(args.directory)}/. {tree.sources['target prefix']}/")
return 0
def cmd_list(cfg, args):
tree = local_tree(cfg, args)
catalog = ws.fetch_catalog(cfg, use_cache=True)
host = ws.host()
log(f"host: {host['os']}/{host['arch']}, core suffix {tree.core_suffix}")
games, skipped = select_games(cfg, catalog, host)
installed = ws.load_state()["installed"]
if not games:
log("no compatible games in the catalog")
for game in sorted(games, key=lambda g: (g["scope"], g["title"].lower())):
record = installed.get(ws.game_key(game))
if not record:
mark = " "
elif record.get("asset") == game["asset"]:
mark = "* "
else:
mark = "^ "
print(f"{mark}{game['platform']:<8} {game['name']:<18} {game['version']:<10} {game['title']}")
if games:
print("\n * installed ^ update available")
for reason in skipped:
log(f"skipped {reason}")
for platform in sorted({g["scope"] for g in games}):
_, core_name, note = resolve_core(cfg, tree, platform)
log(f"{platform}: {note or f'core ready ({core_name})'}")
return 0
def cmd_remove(cfg, args):
guard_running(cfg, args)
tree = local_tree(cfg, args)
state = ws.load_state()
installed = state["installed"]
keys = ws.match_keys(installed, args.name)
if not keys:
log(f"not installed: {', '.join(args.name)}")
return 0
touched = set()
for key in keys:
record = installed[key]
remove_game(cfg, tree, record, dry_run=args.dry_run)
touched.add(ws.record_scope(record))
if not args.dry_run:
del installed[key]
if args.dry_run:
return 0
ws.save_state(state)
grouped = ws.records_by_scope(installed)
for platform in touched:
write_playlist(cfg, tree, platform, grouped.get(platform, []))
return 0
def cmd_paths(cfg, args):
"""Where this machine's RetroArch keeps things, and where each answer came from.
The paths are not in the config — they are worked out from the machine — and
they decide everything the store does, so they are worth being able to look
at on their own. This is the first thing to run when a sync went somewhere
unexpected.
"""
tree = local_tree(cfg, args)
host = ws.host()
print(f"detected: os={host['os']} arch={host['arch']} core_suffix={tree.core_suffix}")
for key in ("retroarch_dir", "retroarch.cfg", "playlists_dir", "thumbnails_dir",
"libretro_dir", "content_root"):
print(f" {key:<16} {tree.sources.get(key, '-')}")
print(f" {'store folder':<16} {tree.owned_root(cfg)}")
for platform in sorted(cfg["platforms"]):
if not cfg["platforms"][platform].get("enabled", True):
continue
spec = cfg["platforms"][platform]
_, core_name, note = resolve_core(cfg, tree, platform)
print(f" {playlist_file_name(playlist_name(cfg, platform, spec))}")
print(f" {'core':<14} {note or f'ready ({core_name})'}")
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)")
ws.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):
parser = argparse.ArgumentParser(
prog=os.path.basename(sys.argv[0]) or "retroarch_store.py",
description="Install WarpEngine catalog releases into RetroArch playlists.",
)
parser.add_argument("--config", default=DEFAULT_CONFIG_PATH,
help=f"store config file (default: {DEFAULT_CONFIG_PATH})")
parser.add_argument("--base-url", help="override the catalog base URL")
parser.add_argument("--retroarch-dir", help="override RetroArch's directory")
parser.add_argument("--core-suffix", help="libretro core file suffix (default: this host's)")
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=f"{VERSION} (warpstore {ws.VERSION})")
sub = parser.add_subparsers(dest="command")
p_sync = sub.add_parser("sync", help="download new/updated games and refresh playlists")
p_sync.add_argument("name", nargs="*", help="limit to these software names")
p_sync.add_argument("--force", action="store_true",
help="write even when RetroArch is running or nothing changed")
p_sync.set_defaults(func=cmd_sync)
p_exp = sub.add_parser("export", help="render the store for another machine (Android, an SD card)")
p_exp.add_argument("directory", help="staging directory to write the RetroArch tree into")
p_exp.add_argument("name", nargs="*", help="limit to these software names")
p_exp.add_argument("--target-prefix", help="how that tree will be spelled on the target machine")
p_exp.add_argument("--target-libretro-dir", help="the target's cores folder, if you know it")
# Also accepted here, where an export for another OS makes it the natural
# thing to reach for. SUPPRESS keeps the global flag's value when omitted.
p_exp.add_argument("--core-suffix", default=argparse.SUPPRESS,
help="libretro core file suffix on the target (e.g. _android.so)")
p_exp.set_defaults(func=cmd_export)
p_list = sub.add_parser("list", help="show compatible catalog entries and core status")
p_list.set_defaults(func=cmd_list)
p_rm = sub.add_parser("remove", help="uninstall games (name or platform:name)")
p_rm.add_argument("name", nargs="+")
p_rm.add_argument("--force", action="store_true", help="write even when RetroArch is running")
p_rm.set_defaults(func=cmd_remove)
p_paths = sub.add_parser("paths", help="show the resolved RetroArch directories and core status")
p_paths.set_defaults(func=cmd_paths)
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
ws.set_verbose(args.verbose)
ws.init(args.config)
cfg = ws.load_config(
args.config, DEFAULT_CONFIG,
overrides={"store.base_url": args.base_url},
required=("paths.subfolder",),
)
os.makedirs(ws.HOME, exist_ok=True)
return args.func(cfg, args)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(130)