import argparse import json import hashlib import pathlib from copy import deepcopy from datetime import datetime from enum import Enum from typing import Any, Dict, Generator, List, NamedTuple, Optional, Union import requests from dateutil import parser API = "https://addons-ecs.forgesvc.net/api/v2" JSON = Union[List[Dict[str, Any]], Dict[str, Any]] class ModLoader(Enum): FORGE = 1 FABRIC = 4 class File(NamedTuple): id: int gameVersions: List[str] name: str modLoader: Optional[ModLoader] date: datetime @classmethod def from_json(cls, f: JSON): assert isinstance(f, dict) assert isinstance(f.get("gameVersion"), list) assert isinstance(f.get("id"), int) assert isinstance(f.get("fileName"), str) assert isinstance(f.get("fileDate"), str) modLoader = ( ModLoader.FORGE if "Forge" in f["gameVersion"] else ModLoader.FABRIC if "Fabric" in f["gameVersion"] else None ) return cls( f["id"], f["gameVersion"], f["fileName"], modLoader, parser.isoparse(f["fileDate"]), ) class CurseAPI: def __init__(self): self._session = requests.Session() self._session.headers[ "User-Agent" ] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0" def get_latest_files(self, mod_id: int) -> Generator[File, None, None]: res = self._session.get(f"{API}/addon/{mod_id}/files") res.raise_for_status() latest_files = res.json() return (File.from_json(f) for f in latest_files) def get_file_url(self, mod_id: int, file_id: int) -> str: res = self._session.get(f"{API}/addon/{mod_id}/file/{file_id}/download-url") res.raise_for_status() return res.text.rstrip("\n") def download_file(self, mod_id: int, file_id: int) -> bytes: url = self.get_file_url(mod_id, file_id) file_ = self._session.get(url) file_.raise_for_status() return file_.content def main(): parser = argparse.ArgumentParser() parser.add_argument("version", help="The minecraft version to limit updates to") parser.add_argument( "--mod-loader", choices=["none", "forge", "fabric"], default="forge" ) parser.add_argument("--infile", type=pathlib.Path) args = parser.parse_args() if args.mod_loader == "forge": mod_loader = ModLoader.FORGE elif args.mod_loader == "fabric": mod_loader = ModLoader.FABRIC else: raise AssertionError("Unreachable") update(args.infile, args.version, mod_loader) def update(infile: pathlib.Path, version: str, mod_loader: ModLoader): with open(infile) as mods_json: mods = json.load(mods_json) curse = CurseAPI() new_mods = [] for mod in mods: print(f"Checking for updates to {mod['project']}...") try: latest_files = list(curse.get_latest_files(mod["project_id"])) except requests.HTTPError as err: print(f"WARNING: Could not access curse API for {mod['project']}: {err}") latest_files = [_ for _ in []] def compatible(file_: File) -> bool: return ( mod_loader is None or file_.modLoader is None or file_.modLoader == mod_loader ) and any( file_version.startswith(version) for file_version in file_.gameVersions ) compatible_files = list(filter(compatible, latest_files)) if compatible_files: latest = max(compatible_files, key=lambda f: f.date) if latest.id != mod["id"]: print( f"Updating {mod['project']} {mod['filename']} -> {latest.name}..." ) contents = curse.download_file(mod["project_id"], latest.id) sha256 = hashlib.sha256(contents).hexdigest() new_mod = deepcopy(mod) new_mod.update( {"filename": latest.name, "id": latest.id, "sha256": sha256} ) new_mods.append(new_mod) else: new_mods.append(mod) else: print(f"WARNING: No compatible files found for {mod['project']}") print( f"Versions available: {[(f.name, f.gameVersions) for f in latest_files]}" ) new_mods.append(mod) with open("temp.json", "w") as out: json.dump(new_mods, out, sort_keys=True, indent=2) if __name__ == "__main__": main()