import argparse import json import hashlib import pathlib from copy import deepcopy from enum import Enum from typing import Dict, Generator, NamedTuple, Optional, Union import requests API = "https://addons-ecs.forgesvc.net/api/v2" class ModLoader(Enum): FORGE = 1 FABRIC = 4 class File(NamedTuple): id: int gameVersion: str name: str modLoader: Optional[ModLoader] @classmethod def from_json(cls, f: Dict[str, Union[str, int]]): modLoader = f.get("modLoader", None) assert isinstance(f["gameVersion"], str) assert isinstance(f["projectFileId"], int) assert isinstance(f["projectFileName"], str) if modLoader is not None: assert isinstance(modLoader, int) return cls( f["projectFileId"], f["gameVersion"], f["projectFileName"], ModLoader(modLoader) if modLoader is not None else None, ) 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}") res.raise_for_status() latest_files = res.json().get("gameVersionLatestFiles", None) if latest_files is None: return (_ for _ in []) else: 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: file_version = file_.gameVersion.split(".") target_version = version.split(".") # We assume that major + minor version are compatible; # this seems to generally be true, but check the output # for possible mistakes. # # The patch version is completely ignored since mod # authors generally don't register versions properly # enough to match this. # # Being more strict than this usually results in # technically compatible mods with no available versions. return ( (file_.modLoader is None or file_.modLoader == mod_loader) and file_version[0] == target_version[0] and file_version[1] == target_version[1] ) latest = max(filter(compatible, latest_files), key=lambda f: f.gameVersion) if latest is None: print(f"WARNING: No compatible files found for {mod['project']}") print( f"Versions available: {[(f.name, f.gameVersion) for f in latest_files]}" ) new_mods.append(mod) elif latest.id != mod["id"]: print(f"Updating {mod['project']}...") 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) with open("temp.json", "w") as out: json.dump(new_mods, out, sort_keys=True, indent=2) if __name__ == "__main__": main()