140 lines
4.3 KiB
Python
Executable File
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()
|