#!/usr/bin/env python3 import json import os import re import urllib.parse import urllib.request from concurrent.futures import ThreadPoolExecutor, as_completed # Configuration TARGET_DIR = "./data/mods" MC_VERSION = "1.20.1" LOADER = "forge" MAX_WORKERS = 8 USER_AGENT = "MCServerModDownloader/2.0 (admin@server.local)" # Explicit Slug Overrides for Modrinth SLUG_OVERRIDES = { "perspatium": "ad-astra-per-spatium-et-tempus", "planets+": "ad-astra-planets", "ad_astra_more_structures": "ad-astra-more-structures", "alexsmobs": "alexs-mobs", "architectury": "architectury-api", "cloth-config": "cloth-config", "create_ad_astra_recipes": "create-ad-astra-recipes", "ferritecore": "ferrite-core", "flan": "flan", "improvedmobs": "improved-mobs", "jei": "jei", "patchouli": "patchouli", "player-animation-lib": "playeranimator", "resourcefulconfig": "resourceful-config", "resourcefullib": "resourceful-lib", "simplebackups": "simple-backups", "sophisticatedcore": "sophisticated-core", "tectonic": "tectonic", "spark": "spark", "tenshilib": "tenshilib", "voicechat": "simple-voice-chat", "yungsapi": "yungs-api", "yungsbetterdungeons": "yungs-better-dungeons", "yungsbettermineshafts": "yungs-better-mineshafts", "yungsbetterstrongholds": "yungs-better-strongholds", } SERVER_MODS = [ "[forge]ctov-3.4.14.jar", "ad_astra-forge-1.20.1-1.15.20.jar", "ad_astra_extra_additions-1.20.1-1.1.1.jar", "ad_astra_more_structures-1.0.1-forge.jar", "alexsmobs-1.22.9.jar", "architectury-9.2.14-forge.jar", "bettercombat-forge-1.9.0+1.20.1.jar", "Botania-1.20.1-454-FORGE.jar", "botarium-forge-1.20.1-2.3.4.jar", "citadel-2.6.3-1.20.1.jar", "cloth-config-11.1.136-forge.jar", "Clumps-forge-1.20.1-12.0.0.4.jar", "connectivity-1.20.1-7.6.jar", "corpse-forge-1.20.1-1.0.23.jar", "create-1.20.1-6.0.8.jar", "create_ad_astra_recipes-1.0.0-forge-1.20.1.jar", "createaddition-1.20.1-1.3.3.jar", "cupboard-1.20.1-3.7.jar", "curios-forge-5.14.1+1.20.1.jar", "decocraft-3.0.4-1.20.1-slim.jar", "DistantHorizons-2.1.2-a-1.20.1.jar", "FarmersDelight-1.20.1-1.3.2.jar", "ferritecore-6.0.1-forge.jar", "flan-1.20.1-1.11.16-forge.jar", "geckolib-forge-1.20.1-4.8.4.jar", "ImmersiveEngineering-1.20.1-10.2.0-183.jar", "improvedmobs-1.20.1-1.13.7-forge.jar", "irons_lib-1.20.1-2.1.0.jar", "irons_spellbooks-1.20.1-3.16.2.jar", "Jade-1.20.1-Forge-11.13.3.jar", "jei-1.20.1-forge-15.48.0.180.jar", "journeymap-forge-1.20.1-6.0.0.jar", "Krypton Reno-forge-1.20.1-26.1.1-1.20.1.jar", "lithostitched-forge-1.20.1-1.4.11.jar", "locks_reforged-1.6.1.jar", "lts_auth-1.0.1+mc1.20.1.jar", "LuckPerms-Forge-5.4.102.jar", "majrusz-library-forge-1.20.1-7.0.8.jar", "majruszs-difficulty-forge-1.20.1-1.9.10.jar", "mcda-5.0.2.jar", "mcdw-9.0.4.jar", "minecraft-comes-alive-7.6.28-beta.10+1.20.1-universal.jar", "modernfix-forge-5.27.66+mc1.20.1.jar", "Nullscape_1.20.x_v1.2.8.jar", "Patchouli-1.20.1-85-FORGE.jar", "perspatium-1.20.1-1.2.0.jar", "planets+-bv1.7.5-1.20x.jar", "player-animation-lib-forge-1.0.2-rc1+1.20.jar", "resourcefulconfig-forge-1.20.1-2.1.3.jar", "resourcefullib-forge-1.20.1-2.1.29.jar", "SimpleBackups-1.20.1-3.1.24.jar", "sophisticatedbackpacks-1.20.1-3.24.63.2049.jar", "sophisticatedcore-1.20.1-1.3.75.2231.jar", "spark-1.10.53-forge.jar", "tectonic-3.0.17-forge-1.20.1.jar", "tenshilib-1.20.1-1.7.6-forge.jar", "Terralith_1.20.x_v2.5.4.jar", "voicechat-forge-1.20.1-2.6.22.jar", "YungsApi-1.20-Forge-4.0.6.jar", "YungsBetterDungeons-1.20-Forge-4.0.4.jar", "YungsBetterMineshafts-1.20-Forge-4.0.4.jar", "YungsBetterStrongholds-1.20-Forge-4.0.3.jar", ] def clean_mod_name(filename): """Strip prefixed metadata and digits to extract purely the mod identifier.""" name = re.sub(r"^\[.*?\]", "", filename) name = re.sub(r"\.jar$", "", name) # Split tokens by hyphen/underscore and keep tokens before version digits tokens = re.split(r"[-_]", name) clean_tokens = [] for token in tokens: if re.match(r"^(v?\d|bv?\d|forge|fabric|quilt|mc1|1\.20|universal|slim)", token, re.I): break clean_tokens.append(token) result = "-".join(clean_tokens).strip("-") return result if result else name def http_get_json(url): req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode("utf-8")) def download_file(url, target_path): req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=30) as resp, open(target_path, "wb") as f: f.write(resp.read()) def process_mod(jar_name): target_path = os.path.join(TARGET_DIR, jar_name) if os.path.exists(target_path): return ("EXISTS", jar_name, "File already in destination") raw_clean = clean_mod_name(jar_name) slug = SLUG_OVERRIDES.get(raw_clean.lower(), raw_clean.lower()) project_id = None # Step 1: Try direct project lookup by slug try: proj_data = http_get_json(f"https://api.modrinth.com/v2/project/{slug}") if proj_data and "id" in proj_data: project_id = proj_data["id"] except Exception: pass # Step 2: Fallback search if direct slug lookup fails if not project_id: try: search_q = slug.replace("-", " ") facets = json.dumps([["categories:" + LOADER], ["versions:" + MC_VERSION]]) query_url = f"https://api.modrinth.com/v2/search?query={urllib.parse.quote(search_q)}&facets={urllib.parse.quote(facets)}" data = http_get_json(query_url) hits = data.get("hits", []) if hits: project_id = hits[0]["project_id"] except Exception: pass if not project_id: return ("NOT_FOUND", jar_name, f"Could not resolve Modrinth project for '{slug}'") # Step 3: Fetch versions and download try: ver_url = f"https://api.modrinth.com/v2/project/{project_id}/version?loaders=[%22{LOADER}%22]&game_versions=[%22{MC_VERSION}%22]" versions = http_get_json(ver_url) if not versions: return ("NOT_FOUND", jar_name, f"No {LOADER} {MC_VERSION} release on Modrinth") download_url = None for ver in versions: for f in ver.get("files", []): if f.get("filename") == jar_name: download_url = f.get("url") break if download_url: break if not download_url and versions[0].get("files"): download_url = versions[0]["files"][0].get("url") if download_url: download_file(download_url, target_path) return ("SUCCESS", jar_name, "Downloaded from Modrinth") else: return ("NOT_FOUND", jar_name, "No download URL available") except Exception as e: return ("ERROR", jar_name, str(e)) def main(): os.makedirs(TARGET_DIR, exist_ok=True) print(f"šŸš€ Starting download for {len(SERVER_MODS)} mods into '{TARGET_DIR}'...") print(f"āš™ļø Target: Minecraft {MC_VERSION} ({LOADER.upper()}) | Workers: {MAX_WORKERS}\n") results = {"SUCCESS": [], "EXISTS": [], "NOT_FOUND": [], "ERROR": []} with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: futures = {executor.submit(process_mod, mod): mod for mod in SERVER_MODS} for future in as_completed(futures): status, jar_name, msg = future.result() results[status].append((jar_name, msg)) symbol = "āœ…" if status == "SUCCESS" else "ā­ļø" if status == "EXISTS" else "āŒ" print(f"{symbol} [{status}] {jar_name} -> {msg}") print("\n" + "=" * 60) print("šŸ“Š DOWNLOAD SUMMARY") print("=" * 60) print(f" āœ… Downloaded: {len(results['SUCCESS'])}") print(f" ā­ļø Skipped: {len(results['EXISTS'])}") print(f" āŒ Not Found: {len(results['NOT_FOUND']) + len(results['ERROR'])}") missing = results['NOT_FOUND'] + results['ERROR'] if missing: print("\nāš ļø The following mods were not found automatically:") for jar_name, msg in missing: print(f" • {jar_name} ({msg})") if __name__ == "__main__": main()