231 lines
8.4 KiB
Python
Executable file
231 lines
8.4 KiB
Python
Executable file
#!/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()
|