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:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,331 @@
|
||||
# warp-engine-retroarch-store — a RetroArch store engine for WarpEngine sites
|
||||
|
||||
Pulls games from a [WarpEngine](https://git.teletypegames.org/engines/warp_engine)
|
||||
catalog into RetroArch's own library: one `.lpl` playlist per platform, with box
|
||||
art in the thumbnail folders that playlist's name points at.
|
||||
|
||||
This repository is the engine only. It knows the WarpEngine API but nothing about
|
||||
any particular site: the host, the store's name and where its games land all come
|
||||
from a `config.json` that lives in a separate **store repository**. The shared
|
||||
part — catalog, releases, state, HTTP — is
|
||||
[`warpstore`](https://git.teletypegames.org/engines/warpstore), which this engine
|
||||
has in common with the [Batocera engine](https://git.teletypegames.org/tools/warp-engine-batocera-store).
|
||||
|
||||
```
|
||||
warpstore the shared core
|
||||
▲
|
||||
warp-engine-retroarch-store this engine — retroarch_store.py, install.sh
|
||||
▲
|
||||
│ config.json
|
||||
ttg-retroarch-store store repositories
|
||||
▼
|
||||
teletypegames.org
|
||||
```
|
||||
|
||||
The reference store is
|
||||
[`ttg-retroarch-store`](https://git.teletypegames.org/tools/ttg-retroarch-store).
|
||||
|
||||
```
|
||||
<store>-retroarch-store sync
|
||||
│
|
||||
├─ GET /api/software the whole catalog
|
||||
├─ keep platforms a libretro core boots c64 → VICE, tic80 → TIC-80
|
||||
├─ pick the newest non-dev release that carries the cartridge
|
||||
├─ GET /api/download?path=<asset> .prg / .tic into the content folder
|
||||
├─ GET <software.imageUrl> box art, converted to PNG
|
||||
└─ write <store> - <label>.lpl playlist + thumbnails
|
||||
```
|
||||
|
||||
Python 3 standard library only.
|
||||
|
||||
## Why this is not the Batocera store
|
||||
|
||||
RetroArch is not one machine. The same playlist format is read on desktop Linux,
|
||||
Windows and macOS, on Android, on the Steam Deck and on the console ports — so
|
||||
the engine assumes nothing about where things are:
|
||||
|
||||
- **It reads `retroarch.cfg`.** The playlist folder is not necessarily inside the
|
||||
RetroArch directory: on the macOS install this was developed against,
|
||||
`playlist_directory` is `~/Documents/RetroArch/playlists` while the cores and
|
||||
thumbnails are under `~/Library/Application Support/RetroArch`. An engine that
|
||||
assumed `<base>/playlists` would write into the void. `~` is expanded, and so is
|
||||
the leading `:` a portable install uses to mean its own directory.
|
||||
- **It can write for a machine it is not running on** (`export`). Android has no
|
||||
Python, so the only way to serve it is to render the tree elsewhere and copy it
|
||||
over. See *Another machine* below.
|
||||
- **It never edits `retroarch.cfg`.** Nothing the user configured is touched.
|
||||
|
||||
What it does *not* do, compared with Batocera: a `.lpl` entry has no description
|
||||
or developer field, so the catalog's `desc` and `author` are lost — RetroArch
|
||||
takes that from its own databases, which are a binary format outside this
|
||||
engine's scope. And RetroArch cannot launch scripts, so there is no in-app
|
||||
trigger: the sync runs from a shell or from a scheduler.
|
||||
|
||||
## Installing a store
|
||||
|
||||
The installer takes the store as a parameter, so it works with any config:
|
||||
|
||||
```sh
|
||||
STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json \
|
||||
bash <(curl -fsSL https://git.teletypegames.org/tools/warp-engine-retroarch-store/raw/branch/master/install.sh)
|
||||
```
|
||||
|
||||
From a checkout, with a local config:
|
||||
|
||||
```sh
|
||||
STORE_CONFIG=./my-config.json ./install.sh
|
||||
```
|
||||
|
||||
It reads `store.id` from the config, installs the engine, the shared core and the
|
||||
config into `~/.local/share/warp-engine-store/<id>/`, writes a
|
||||
`<id>-retroarch-store` launcher into `~/.local/bin`, prints the paths it resolved
|
||||
and runs the first sync.
|
||||
|
||||
| Environment variable | Meaning |
|
||||
|---|---|
|
||||
| `STORE_CONFIG` | **required** — path or URL of the store's `config.json` |
|
||||
| `STORE_ROOT` | where stores live (default `${XDG_DATA_HOME:-~/.local/share}/warp-engine-store`) |
|
||||
| `BIN_DIR` | where the launcher goes (default `~/.local/bin`) |
|
||||
| `RETROARCH_DIR` | RetroArch's directory, if it is somewhere unusual |
|
||||
| `STORE_SKIP_SYNC` | `1` to install without syncing |
|
||||
| `ENGINE_RAW_BASE` | where to fetch `retroarch_store.py` from |
|
||||
| `WARPSTORE_RAW_BASE` | where to fetch `warpstore.py` from |
|
||||
| `WARPSTORE_SRC` | a local `warpstore.py` to install instead of downloading it |
|
||||
|
||||
## Use
|
||||
|
||||
```sh
|
||||
S=~/.local/bin/example-retroarch-store
|
||||
|
||||
$S paths # resolved directories and whether the cores are there
|
||||
$S list # compatible catalog entries; * installed, ^ update available
|
||||
$S sync # download everything new, refresh the playlists
|
||||
$S sync blessingofra # just one title (never prunes)
|
||||
$S -n sync # dry run
|
||||
$S remove c64:c64demo # uninstall (bare name works too)
|
||||
$S config # effective configuration
|
||||
```
|
||||
|
||||
Global flags go **before** the subcommand: `$S --retroarch-dir /mnt/ra sync`.
|
||||
|
||||
**Restart RetroArch after a sync.** It builds its menu at startup, so a new
|
||||
playlist does not appear in a running instance.
|
||||
|
||||
**A sync refuses to run while RetroArch is up.** RetroArch holds its playlists in
|
||||
memory and writes them back when it exits — favourites, last played, sort order —
|
||||
so writing underneath it can be undone. Close it, or pass `--force` if you know
|
||||
what you are doing (`behavior.refuse_while_running: false` to stop asking).
|
||||
|
||||
## Where things land
|
||||
|
||||
```
|
||||
~/.local/share/warp-engine-store/example/
|
||||
├── retroarch_store.py, warpstore.py, config.json
|
||||
└── state.json, catalog.json
|
||||
|
||||
<content_root>/example/c64/ blessingofra-2.0.0.prg, rabbit-1.0.0.prg, …
|
||||
<playlists_dir>/Example Store - Commodore 64.lpl
|
||||
<thumbnails_dir>/Example Store - Commodore 64/Named_Boxarts/Blessing of Ra.png
|
||||
/Named_Titles/… /Named_Snaps/…
|
||||
```
|
||||
|
||||
`content_root` defaults to `<retroarch_dir>/content`; the other two come from
|
||||
`retroarch.cfg`. Run `paths` to see what was resolved and why.
|
||||
|
||||
Everything lands under the store's own `paths.subfolder`, which is what a prune
|
||||
is allowed to touch — never content you put there yourself, never another
|
||||
store's. The playlist file carries the store's name, so it is ours; but an entry
|
||||
*you* added to it survives a rewrite, because only items pointing inside the
|
||||
store's content folder are replaced. The first time a playlist is touched it is
|
||||
copied to `<name>.lpl.<store id>-backup`.
|
||||
|
||||
## The playlist
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.5",
|
||||
"default_core_path": "/…/cores/vice_x64_libretro.dylib",
|
||||
"default_core_name": "VICE x64",
|
||||
"label_display_mode": 0,
|
||||
"right_thumbnail_mode": 0,
|
||||
"left_thumbnail_mode": 0,
|
||||
"thumbnail_match_mode": 0,
|
||||
"sort_mode": 0,
|
||||
"items": [
|
||||
{
|
||||
"path": "/…/content/example/c64/blessingofra-2.0.0.prg",
|
||||
"entry_slot": -1,
|
||||
"label": "Blessing of Ra",
|
||||
"core_path": "DETECT",
|
||||
"core_name": "DETECT",
|
||||
"crc32": "4253CFC6|crc",
|
||||
"db_name": "Example Store - Commodore 64.lpl"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **One playlist per platform**, because `default_core_path` is a *header* field:
|
||||
that is the only way every entry starts with one click and no core prompt. The
|
||||
entries themselves stay on `DETECT`, so the file still works on a machine whose
|
||||
cores live somewhere else — the worst case is one prompt, not a dead entry.
|
||||
- **The name is the namespace.** `{store} - {label}` means we never write into
|
||||
RetroArch's own `Commodore - 64.lpl` and never collide with the official
|
||||
thumbnail packs. It is also the `db_name`, which is how RetroArch finds the
|
||||
thumbnails: `<thumbnails_dir>/<db_name without .lpl>/Named_*/`.
|
||||
- **The display fields are all `0`** — whatever the user has set globally. A store
|
||||
has no business overriding how someone likes their library displayed.
|
||||
- **`crc32` is real**, computed from the downloaded file, which lets RetroArch tie
|
||||
saves and thumbnails to the content. Turn it off with `playlist.write_crc32`.
|
||||
|
||||
### A missing core is a note, not an error
|
||||
|
||||
If the core named for a platform is not in `libretro_directory`, the playlist is
|
||||
still written: `default_core_path` stays empty and the entries stay on `DETECT`,
|
||||
so RetroArch asks once which core to use. `list` and `paths` say so, with the fix:
|
||||
|
||||
```
|
||||
c64: core not found: vice_x64_libretro.dylib — entries stay on DETECT
|
||||
(Online Updater ▸ Core Downloader ▸ VICE x64)
|
||||
```
|
||||
|
||||
A store that refused to run until the user had installed cores would never run
|
||||
for anyone the first time.
|
||||
|
||||
### Box art must be PNG
|
||||
|
||||
RetroArch looks for `<label>.png` and nothing else. The catalog serves whatever
|
||||
was uploaded — one of ours is a GIF — and the standard library cannot re-encode an
|
||||
image, so the engine shells out to whatever the machine has: `sips` (built into
|
||||
macOS), `magick`, `convert` or `ffmpeg`, first one found. With none of them the
|
||||
entry simply has no box art, which beats a failed sync.
|
||||
|
||||
The cover is downloaded once and placed in all three `Named_*` folders (hardlinked
|
||||
where the filesystem allows), so whichever thumbnail type the user has selected,
|
||||
there is an image for it. `playlist.thumbnail_kinds` narrows that.
|
||||
|
||||
## Another machine: `export`
|
||||
|
||||
`sync` installs into this machine's RetroArch. `export` renders the whole store
|
||||
into a canonical tree meant for somewhere else — the only way to serve Android,
|
||||
which runs RetroArch but cannot run Python.
|
||||
|
||||
```sh
|
||||
# Android over adb
|
||||
$S export /tmp/ra-example \
|
||||
--target-prefix /storage/emulated/0/RetroArch \
|
||||
--target-libretro-dir /data/data/com.retroarch.aarch64/cores \
|
||||
--core-suffix _android.so
|
||||
adb push /tmp/ra-example/. /storage/emulated/0/RetroArch/
|
||||
|
||||
# An SD card mounted here, but at /mnt/sdcard on the handheld
|
||||
$S export /Volumes/SDCARD/RetroArch --target-prefix /mnt/sdcard/RetroArch
|
||||
```
|
||||
|
||||
The tree is always `<dir>/playlists`, `<dir>/thumbnails`, `<dir>/content`.
|
||||
`--target-prefix` is what the playlists say the content is called on the machine
|
||||
that will run it; without it, the export directory's own path is used (right when
|
||||
the export lands where it will stay). Target paths are written the way the target
|
||||
spells them — POSIX unless the prefix looks like Windows.
|
||||
|
||||
`export` is **stateless on purpose**: it is a complete picture of the catalog, not
|
||||
an incremental update, and it must not disturb the state of a local install living
|
||||
in the same store home. `--target-libretro-dir` is taken on trust, since the other
|
||||
machine cannot be inspected; leave it out and the entries stay on `DETECT`.
|
||||
|
||||
**Not verified on a device yet.** The Android paths above are the documented
|
||||
defaults, not measurements — read the device's own `retroarch.cfg` if a push does
|
||||
not show up, and whether scoped storage lets RetroArch read pushed content is the
|
||||
open question.
|
||||
|
||||
## Writing a store repository
|
||||
|
||||
Three files:
|
||||
|
||||
```
|
||||
my-retroarch-store/
|
||||
├── config.json the store: URL, name, subfolder, platform → core mapping
|
||||
├── install.sh a wrapper that hands that config to the engine installer
|
||||
└── README.md
|
||||
```
|
||||
|
||||
`install.sh` in full:
|
||||
|
||||
```sh
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/tools/warp-engine-retroarch-store/raw/branch/master}"
|
||||
export STORE_CONFIG="${STORE_CONFIG:-https://git.example.org/tools/my-retroarch-store/raw/branch/master/config.json}"
|
||||
curl -fsSL "$ENGINE_RAW_BASE/install.sh" | bash
|
||||
```
|
||||
|
||||
Pick a `store.id` and a `paths.subfolder` nobody else uses — they are what keeps
|
||||
two stores on one machine from deleting each other's games.
|
||||
|
||||
## Configuration
|
||||
|
||||
`config.json` in the store home, template in `config.example.json`
|
||||
(`config --write` regenerates it):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `store.id` | `warp` | slug: names the store home, log prefix and backups |
|
||||
| `store.name` | `WarpEngine Store` | goes into the playlist name — what the user sees |
|
||||
| `store.base_url` | — | WarpEngine host |
|
||||
| `store.api.catalog` / `.download` | `/api/software`, `/api/download` | endpoints, if the engine is mounted elsewhere |
|
||||
| `paths.retroarch_dir` | `null` | RetroArch's directory; null = env, then the per-OS candidates |
|
||||
| `paths.playlists_dir` | `null` | null = from `retroarch.cfg`, then `<retroarch_dir>/playlists` |
|
||||
| `paths.thumbnails_dir` | `null` | as above, `thumbnails` |
|
||||
| `paths.libretro_dir` | `null` | as above, `cores` |
|
||||
| `paths.content_root` | `null` | null = `<retroarch_dir>/content` |
|
||||
| `paths.subfolder` | `warp` | this store's folder inside it — the prune boundary |
|
||||
| `paths.target_prefix` | `null` | default for `export --target-prefix` |
|
||||
| `playlist.name_template` | `{store} - {label}` | `store`, `label`, `platform` are substituted |
|
||||
| `playlist.write_crc32` | `true` | compute the content CRC32 for each entry |
|
||||
| `playlist.thumbnail_kinds` | all three `Named_*` | which thumbnail folders to fill |
|
||||
| `catalog.statuses` | `["released", "archived"]` | catalog `status` values to install |
|
||||
| `catalog.owner_id` | `null` | restrict to one publisher |
|
||||
| `catalog.only` / `catalog.exclude` | `[]` | software-name allow / deny lists |
|
||||
| `platforms` | c64, tic80 | platform → label, asset kind, extension, core, enabled |
|
||||
| `behavior.prune` | `true` | remove games that left the catalog or the filters |
|
||||
| `behavior.timeout` | `30` | HTTP timeout, seconds |
|
||||
| `behavior.insecure` | `false` | skip TLS verification (self-hosted test instances) |
|
||||
| `behavior.refuse_while_running` | `true` | do not write while RetroArch is running |
|
||||
| `behavior.convert_images` | `true` | convert non-PNG box art with an external tool |
|
||||
|
||||
### Platform mapping
|
||||
|
||||
A catalog platform is installable when a libretro core boots its asset directly:
|
||||
|
||||
| Catalog platform | Asset kind | Core | Extension |
|
||||
| --- | --- | --- | --- |
|
||||
| `c64` | `cartridge` | `vice_x64_libretro` (VICE x64) | `.prg` |
|
||||
| `tic80` | `cartridge` | `tic80_libretro` (TIC-80) | `.tic` |
|
||||
|
||||
Both cores are built for every relevant target — macOS x86_64/arm64, Linux
|
||||
x86_64/aarch64/armhf, Windows x86_64, Android arm64-v8a/armeabi-v7a — so the
|
||||
catalogue's cartridges run everywhere RetroArch does. Adding a platform is a
|
||||
config edit:
|
||||
|
||||
```json
|
||||
"platforms": {
|
||||
"nes": { "label": "NES", "kind": "cartridge", "ext": ".nes",
|
||||
"core": { "file": "fceumm_libretro", "name": "FCEUmm" }, "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
`kind` and `ext` may be per-host maps rather than strings — `{"windows": …, "*": …}`,
|
||||
see `warpstore.resolve_for_host` — but for a cartridge they never need to be: it
|
||||
is data for an emulator, identical on every machine.
|
||||
|
||||
The other WarpEngine platforms (`ebitengine`, `love`, `godot`, `bevy`, `phaser`)
|
||||
ship web builds and per-OS native archives. No libretro core runs those, so this
|
||||
engine cannot serve them; on Batocera they install as Ports instead.
|
||||
|
||||
## State
|
||||
|
||||
`state.json` records what this store installed, keyed `<platform>:<name>` so the
|
||||
same software name on two platforms stays two entries. Re-running a sync is
|
||||
idempotent: files already present are left alone, and a release bump
|
||||
(`1.0` → `2.0.0`) deletes the old asset, its thumbnails and its playlist entry
|
||||
before fetching the new one.
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"store": {
|
||||
"id": "warp",
|
||||
"name": "WarpEngine Store",
|
||||
"base_url": "https://example.org",
|
||||
"api": {
|
||||
"catalog": "/api/software",
|
||||
"download": "/api/download"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"retroarch_dir": null,
|
||||
"playlists_dir": null,
|
||||
"thumbnails_dir": null,
|
||||
"libretro_dir": null,
|
||||
"content_root": null,
|
||||
"subfolder": "warp",
|
||||
"target_prefix": null
|
||||
},
|
||||
"playlist": {
|
||||
"name_template": "{store} - {label}",
|
||||
"write_crc32": true,
|
||||
"thumbnail_kinds": [
|
||||
"Named_Boxarts",
|
||||
"Named_Titles",
|
||||
"Named_Snaps"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"statuses": [
|
||||
"released",
|
||||
"archived"
|
||||
],
|
||||
"owner_id": null,
|
||||
"only": [],
|
||||
"exclude": []
|
||||
},
|
||||
"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,
|
||||
"refuse_while_running": true,
|
||||
"convert_images": true
|
||||
}
|
||||
}
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
# Install a WarpEngine store into a RetroArch installation.
|
||||
#
|
||||
# This installer belongs to the engine, so it takes the store as a parameter:
|
||||
# point STORE_CONFIG at a config.json — a local path or a URL — and it sets up
|
||||
# that store. A store repository's own install.sh is a short wrapper that
|
||||
# supplies its config; see README.
|
||||
#
|
||||
# STORE_CONFIG=./config.json ./install.sh
|
||||
# STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json \
|
||||
# bash <(curl -fsSL https://git.teletypegames.org/tools/warp-engine-retroarch-store/raw/branch/master/install.sh)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
STORE_ROOT="${STORE_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/warp-engine-store}"
|
||||
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
|
||||
ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/tools/warp-engine-retroarch-store/raw/branch/master}"
|
||||
# The shared core lives in its own repository, because both store engines use it.
|
||||
WARPSTORE_RAW_BASE="${WARPSTORE_RAW_BASE:-https://git.teletypegames.org/engines/warpstore/raw/branch/master}"
|
||||
STORE_CONFIG="${STORE_CONFIG:-}"
|
||||
# Only set when run as a file; piped from curl "$0" is `bash`, and then the
|
||||
# engine has to come from the forge rather than from whatever the cwd holds.
|
||||
SRC_DIR=""
|
||||
if [ -f "$0" ]; then
|
||||
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
fi
|
||||
|
||||
say() { echo "[install] $*"; }
|
||||
die() { echo "[install] error: $*" >&2; exit 1; }
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || die "python3 is required"
|
||||
command -v curl >/dev/null 2>&1 || die "curl is required"
|
||||
[ -n "$STORE_CONFIG" ] || die "STORE_CONFIG is required (path or URL of a store config.json)"
|
||||
|
||||
fetch() { # fetch <source> <dest>; source may be a local path or a URL
|
||||
case "$1" in
|
||||
http://*|https://*) curl -fsSL "$1" -o "$2" ;;
|
||||
*) [ -f "$1" ] || die "no such file: $1"; cp "$1" "$2" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Stage the config first: the store id in it decides where everything lands.
|
||||
TMP_CFG="$(mktemp)"
|
||||
trap 'rm -f "$TMP_CFG"' EXIT
|
||||
say "reading store config from $STORE_CONFIG"
|
||||
fetch "$STORE_CONFIG" "$TMP_CFG"
|
||||
|
||||
STORE_META="$(python3 -c '
|
||||
import json, sys
|
||||
cfg = json.load(open(sys.argv[1])).get("store") or {}
|
||||
sid = cfg.get("id") or ""
|
||||
if not sid or "/" in sid:
|
||||
sys.exit("store.id is required and must not contain a slash")
|
||||
print(sid, cfg.get("name") or sid)
|
||||
' "$TMP_CFG")" || die "cannot read $STORE_CONFIG"
|
||||
|
||||
STORE_ID="${STORE_META%% *}"
|
||||
STORE_NAME="${STORE_META#* }"
|
||||
|
||||
STORE_HOME="$STORE_ROOT/$STORE_ID"
|
||||
LAUNCHER="$BIN_DIR/$STORE_ID-retroarch-store"
|
||||
|
||||
mkdir -p "$STORE_HOME" "$BIN_DIR"
|
||||
|
||||
install_file() { # install_file <name> <raw base> [local override]
|
||||
if [ -n "${3:-}" ] && [ -f "${3:-}" ]; then
|
||||
say "installing $1 from ${3}"
|
||||
install -m 0755 "$3" "$STORE_HOME/$1"
|
||||
elif [ -n "$SRC_DIR" ] && [ -f "$SRC_DIR/$1" ]; then
|
||||
say "installing $1 from $SRC_DIR"
|
||||
install -m 0755 "$SRC_DIR/$1" "$STORE_HOME/$1"
|
||||
else
|
||||
say "downloading $1 from $2"
|
||||
curl -fsSL "$2/$1" -o "$STORE_HOME/$1"
|
||||
chmod 0755 "$STORE_HOME/$1"
|
||||
fi
|
||||
}
|
||||
|
||||
install_file retroarch_store.py "$ENGINE_RAW_BASE"
|
||||
install_file warpstore.py "$WARPSTORE_RAW_BASE" "${WARPSTORE_SRC:-}"
|
||||
|
||||
# The config is the store's identity, so it is always refreshed; state.json,
|
||||
# catalog.json and the log stay put, which makes re-running the upgrade path.
|
||||
if [ -f "$STORE_HOME/config.json" ]; then
|
||||
say "updating $STORE_HOME/config.json (previous copy kept as config.json.bak)"
|
||||
cp "$STORE_HOME/config.json" "$STORE_HOME/config.json.bak"
|
||||
fi
|
||||
install -m 0644 "$TMP_CFG" "$STORE_HOME/config.json"
|
||||
|
||||
# A short launcher so the CLI is one path, not a python invocation.
|
||||
cat > "$LAUNCHER" <<EOF
|
||||
#!/bin/bash
|
||||
# $STORE_NAME — CLI wrapper around the shared RetroArch store engine.
|
||||
export RETROARCH_STORE_HOME="$STORE_HOME"
|
||||
exec "$STORE_HOME/retroarch_store.py" --config "$STORE_HOME/config.json" "\$@"
|
||||
EOF
|
||||
chmod 0755 "$LAUNCHER"
|
||||
|
||||
say "resolved paths:"
|
||||
"$LAUNCHER" paths 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
if [ "${STORE_SKIP_SYNC:-0}" != "1" ]; then
|
||||
say "running the first sync"
|
||||
"$LAUNCHER" --verbose sync || say "the first sync did not finish — see the message above"
|
||||
fi
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) ;;
|
||||
*) say "note: $BIN_DIR is not on your PATH" ;;
|
||||
esac
|
||||
|
||||
cat <<EOF
|
||||
|
||||
[install] done.
|
||||
|
||||
CLI: $LAUNCHER list|sync|remove|export|paths|config
|
||||
Config: $STORE_HOME/config.json
|
||||
|
||||
Restart RetroArch to see the playlists — it builds its menu at startup.
|
||||
|
||||
Keep it up to date by running the sync on a schedule, e.g. in crontab:
|
||||
0 * * * * $LAUNCHER sync >>$STORE_HOME/store.log 2>&1
|
||||
EOF
|
||||
Executable
+977
@@ -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)
|
||||
Reference in New Issue
Block a user