Files
NX_Firmware/generate_fw_archive.py
2026-04-15 21:25:34 +02:00

140 lines
4.3 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Fetch Nintendo Switch firmware release metadata from THZoria/NX_Firmware via the GitHub API
and write a JSON list of {tag, label, download} for each release that has a .zip asset.
Example:
python3 generate_fw_archive.py
python3 generate_fw_archive.py -o archive.json --min 18.0.0
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_REPO = "THZoria/NX_Firmware"
DEFAULT_MIN_FW = "18.0.0"
DEFAULT_OUTPUT = "archive.json"
API_BASE = "https://api.github.com"
FW_TAG_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def parse_fw_tag(tag: str) -> tuple[int, int, int] | None:
m = FW_TAG_RE.match(tag.strip())
if not m:
return None
return int(m.group(1)), int(m.group(2)), int(m.group(3))
def parse_min_version(s: str) -> tuple[int, int, int]:
v = parse_fw_tag(s)
if v is None:
raise argparse.ArgumentTypeError("must look like MAJOR.MINOR.PATCH, e.g. 18.0.0")
return v
def fetch_releases(repo: str) -> list[dict[str, Any]]:
"""Paginate GET /repos/{owner}/{repo}/releases."""
releases: list[dict[str, Any]] = []
page = 1
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "generate-nx-firmware-json",
}
while True:
url = f"{API_BASE}/repos/{repo}/releases?per_page=100&page={page}"
req = Request(url, headers=headers)
try:
with urlopen(req, timeout=60) as resp:
body = resp.read().decode("utf-8")
except HTTPError as e:
raise SystemExit(f"GitHub API error: {e.code} {e.reason}\n{e.read().decode('utf-8', errors='replace')}") from e
except URLError as e:
raise SystemExit(f"Request failed: {e}") from e
chunk = json.loads(body)
if not isinstance(chunk, list):
raise SystemExit("Unexpected API response (expected a JSON array)")
releases.extend(chunk)
if len(chunk) < 100:
break
page += 1
return releases
def pick_zip_url(assets: list[dict[str, Any]]) -> str | None:
zips = [a for a in assets if str(a.get("name", "")).lower().endswith(".zip")]
if not zips:
return None
zips.sort(key=lambda a: (0 if str(a.get("name", "")).lower().startswith("firmware") else 1, a.get("name", "")))
return str(zips[0].get("browser_download_url") or "")
def build_entries(
releases: list[dict[str, Any]],
min_version: tuple[int, int, int],
) -> list[dict[str, str]]:
out: list[dict[str, str]] = []
for rel in releases:
if rel.get("draft"):
continue
tag = str(rel.get("tag_name", "")).strip()
ver = parse_fw_tag(tag)
if ver is None or ver < min_version:
continue
url = pick_zip_url(rel.get("assets") or [])
if not url:
continue
name = str(rel.get("name") or "").strip()
label = name if name else f"Firmware {tag}"
out.append({"tag": tag, "label": label, "download": url})
out.sort(key=lambda e: parse_fw_tag(e["tag"]) or (0, 0, 0), reverse=True)
return out
def main() -> None:
p = argparse.ArgumentParser(description="Build NX firmware JSON index from GitHub releases.")
p.add_argument(
"--repo",
default=DEFAULT_REPO,
help=f"owner/repo (default: {DEFAULT_REPO})",
)
p.add_argument(
"--min",
dest="min_fw",
type=parse_min_version,
default=parse_min_version(DEFAULT_MIN_FW),
help=f"minimum firmware tag to include (default: {DEFAULT_MIN_FW})",
)
p.add_argument(
"-o",
"--output",
default=DEFAULT_OUTPUT,
help=f"write JSON to this file (default: {DEFAULT_OUTPUT}); use - for stdout",
)
args = p.parse_args()
releases = fetch_releases(args.repo)
entries = build_entries(releases, args.min_fw)
text = json.dumps(entries, indent=2) + "\n"
if args.output == "-":
sys.stdout.write(text)
else:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
print(f"Wrote {len(entries)} entries to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()