Created
April 12, 2026 06:41
-
-
Save dark-swordsman/c0f631fd3ee5a3dc166daec4482a2825 to your computer and use it in GitHub Desktop.
CurseForge Server Mods Downloader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # -*- coding: utf-8 -*- | |
| # CurseForge Modpack Downloader | |
| # Reads manifest.json and downloads all mods + NeoForge installer | |
| # Created by darkswordsman (darkswordsman.com) | |
| # | |
| # Before running: | |
| # 1. Export your modpack from CurseForge and extract the zip | |
| # 2. Copy manifest.json into your server folder alongside this script | |
| # 3. Copy the contents of the overrides/ folder into your server folder | |
| # 4. Run: `python download.py` (or `sudo python3 download.py`) | |
| # | |
| # Optional: Set your CurseForge API key below to enable client-only mod detection | |
| # Get a free key at https://console.curseforge.com | |
| CF_API_KEY = "" | |
| # CurseForge class IDs for Minecraft | |
| # 6 = Mods, 12 = Resource Packs, 6552 = Shaders, 17 = Worlds, 4471 = Modpacks | |
| # Anything not in this set will be skipped as non-mod content | |
| SERVER_COMPATIBLE_CLASSES = {6} # Only actual mods belong on a server | |
| # Known client-only or non-mod files to always skip, regardless of API response. | |
| # Add project IDs here if you find mods that slip through the API check. | |
| KNOWN_CLIENT_ONLY = { | |
| 394468, # Sodium | |
| 511319, # Reese's Sodium Options | |
| 447673, # Sodium Extra | |
| 1089479, # Sodium Leaf Culling | |
| 1103431, # Sodium Options API | |
| 551736, # Sodium Dynamic Lights | |
| 455508, # Iris | |
| 568563, # Entity Texture Features (ETF) | |
| 844662, # Entity Model Features (EMF) | |
| 686911, # ImmediatelyFast | |
| 448233, # EntityCulling | |
| 882495, # GPU Memory Leak Fix | |
| 521590, # Highlighter | |
| 561885, # Just Zoom | |
| 511770, # Drippy Loading Screen | |
| 1167848, # Distraction Free Recipes (EMI) | |
| 433760, # NotEnoughAnimations (bundles TRansition client mod) | |
| } | |
| import json | |
| import os | |
| import requests | |
| import time | |
| with open("manifest.json") as f: | |
| manifest = json.load(f) | |
| os.makedirs("mods", exist_ok=True) | |
| # Build a set of project IDs that are already fully downloaded | |
| print("Scanning mods/ folder for already-downloaded mods...") | |
| already_downloaded = set() | |
| for filename in os.listdir("mods"): | |
| parts = filename.split("-") | |
| if len(parts) >= 2: | |
| try: | |
| already_downloaded.add(int(parts[0])) | |
| except ValueError: | |
| pass | |
| print(f" Found {len(already_downloaded)} mods already downloaded.") | |
| # Figure out which mods still need to be processed | |
| pending = [mod for mod in manifest["files"] if mod["projectID"] not in already_downloaded] | |
| print(f" {len(pending)} mods need to be checked/downloaded.") | |
| # Download NeoForge installer | |
| neoforge_version = None | |
| for loader in manifest["minecraft"]["modLoaders"]: | |
| if loader["id"].startswith("neoforge-"): | |
| neoforge_version = loader["id"].replace("neoforge-", "") | |
| break | |
| if neoforge_version: | |
| installer_name = f"neoforge-{neoforge_version}-installer.jar" | |
| if os.path.exists(installer_name): | |
| print(f"\nNeoForge installer already exists, skipping.") | |
| else: | |
| print(f"\nDownloading NeoForge {neoforge_version} installer...") | |
| neoforge_url = ( | |
| f"https://maven.neoforged.net/releases/net/neoforged/neoforge/" | |
| f"{neoforge_version}/neoforge-{neoforge_version}-installer.jar" | |
| ) | |
| try: | |
| r = requests.get(neoforge_url, timeout=60) | |
| r.raise_for_status() | |
| with open(installer_name, "wb") as f: | |
| f.write(r.content) | |
| print(f" Saved as {installer_name}") | |
| except Exception as e: | |
| print(f" ERROR downloading NeoForge: {e}") | |
| else: | |
| print("No NeoForge version found in manifest, skipping.") | |
| # Check pending mods against client-only lists and project type | |
| client_only_ids = set(KNOWN_CLIENT_ONLY) | |
| non_mod_ids = set() | |
| if not pending: | |
| print("\nAll mods already downloaded, skipping API check.") | |
| else: | |
| if CF_API_KEY: | |
| print(f"\nChecking {len(pending)} pending mods...") | |
| headers = { | |
| "x-api-key": CF_API_KEY, | |
| "Accept": "application/json", | |
| "User-Agent": "Mozilla/5.0" | |
| } | |
| for mod in pending: | |
| project_id = mod["projectID"] | |
| file_id = mod["fileID"] | |
| if project_id in KNOWN_CLIENT_ONLY: | |
| print(f" KNOWN CLIENT-ONLY: {project_id}/{file_id}, will skip") | |
| continue | |
| try: | |
| # Check project type (mod vs resource pack vs shader etc.) | |
| proj_r = requests.get( | |
| f"https://api.curseforge.com/v1/mods/{project_id}", | |
| headers=headers, | |
| timeout=10 | |
| ) | |
| proj_r.raise_for_status() | |
| proj_data = proj_r.json().get("data", {}) | |
| class_id = proj_data.get("classId") | |
| if class_id not in SERVER_COMPATIBLE_CLASSES: | |
| non_mod_ids.add(project_id) | |
| class_name = { | |
| 12: "Resource Pack", | |
| 6552: "Shader", | |
| 17: "World", | |
| 4471: "Modpack", | |
| }.get(class_id, f"Non-mod (classId={class_id})") | |
| print(f" SKIPPING: {project_id}/{file_id} is a {class_name}") | |
| time.sleep(0.1) | |
| continue | |
| # Check client/server compatibility | |
| file_r = requests.get( | |
| f"https://api.curseforge.com/v1/mods/{project_id}/files/{file_id}", | |
| headers=headers, | |
| timeout=10 | |
| ) | |
| file_r.raise_for_status() | |
| file_data = file_r.json().get("data", {}) | |
| versions = file_data.get("gameVersions", []) | |
| has_client = "Client" in versions | |
| has_server = "Server" in versions | |
| if has_client and not has_server: | |
| client_only_ids.add(project_id) | |
| print(f" CLIENT-ONLY: {project_id}/{file_id}, will skip") | |
| time.sleep(0.1) | |
| except Exception as e: | |
| print(f" Could not check {project_id}/{file_id}: {e}") | |
| all_skipped = client_only_ids | non_mod_ids | |
| print(f"\nAPI check complete:") | |
| print(f" Client-only mods: {len(client_only_ids)}") | |
| print(f" Non-mod files (resource packs, shaders, etc.): {len(non_mod_ids)}") | |
| else: | |
| print("\nNo CF_API_KEY set - skipping client-only detection.") | |
| print("Get a free key at https://console.curseforge.com to enable this.") | |
| all_skipped = client_only_ids | non_mod_ids | |
| # Remove any already-downloaded files that should be skipped | |
| print(f"\nChecking for files to remove from mods/ folder...") | |
| removed = 0 | |
| for project_id in all_skipped: | |
| for filename in os.listdir("mods"): | |
| if filename.startswith(f"{project_id}-"): | |
| path = os.path.join("mods", filename) | |
| os.remove(path) | |
| print(f" REMOVED: {filename}") | |
| removed += 1 | |
| if removed == 0: | |
| print(" Nothing to remove.") | |
| # Adaptive delay settings | |
| delay = 0.5 # starting delay in seconds | |
| min_delay = 0.01 # floor - won't go below this | |
| speedup = 0.80 # multiply delay by this on success | |
| slowdown = 2.0 # multiply delay by this on rejection | |
| max_delay = 10.0 # ceiling after repeated rejections | |
| # Download mods | |
| files = manifest["files"] | |
| print(f"\nDownloading mods...") | |
| for i, mod in enumerate(files): | |
| project_id = mod["projectID"] | |
| file_id = mod["fileID"] | |
| url = f"https://www.curseforge.com/api/v1/mods/{project_id}/files/{file_id}/download" | |
| # Skip client-only and non-mod files | |
| if project_id in all_skipped: | |
| print(f"[{i+1}/{len(files)}] Skipping {project_id}/{file_id}.") | |
| continue | |
| # Skip if already downloaded | |
| existing = [f for f in os.listdir("mods") if f.startswith(f"{project_id}-{file_id}")] | |
| if existing: | |
| print(f"[{i+1}/{len(files)}] Skipping {project_id}/{file_id}, already exists.") | |
| continue | |
| print(f"[{i+1}/{len(files)}] Downloading {project_id}/{file_id}... (delay: {delay:.2f}s)") | |
| try: | |
| r = requests.get(url, allow_redirects=True, timeout=30, | |
| headers={"User-Agent": "Mozilla/5.0"}) | |
| if r.status_code == 429: | |
| delay = min(delay * slowdown, max_delay) | |
| print(f" Rate limited! Backing off to {delay:.2f}s and retrying...") | |
| time.sleep(delay) | |
| continue | |
| r.raise_for_status() | |
| filename = None | |
| if "Content-Disposition" in r.headers: | |
| cd = r.headers["Content-Disposition"] | |
| if "filename=" in cd: | |
| filename = cd.split("filename=")[-1].strip('"') | |
| if not filename: | |
| filename = f"{project_id}-{file_id}.jar" | |
| with open(os.path.join("mods", filename), "wb") as f: | |
| f.write(r.content) | |
| delay = max(delay * speedup, min_delay) | |
| except Exception as e: | |
| print(f" ERROR: {e}") | |
| delay = min(delay * slowdown, max_delay) | |
| time.sleep(delay) | |
| print("\n--- Summary ---") | |
| print(f" Client-only mods skipped: {len(client_only_ids)}") | |
| print(f" Non-mod files skipped (resource packs, shaders, etc.): {len(non_mod_ids)}") | |
| print(f" Files removed from mods/ folder: {removed}") | |
| skipped_existing = sum( | |
| 1 for mod in files | |
| if mod["projectID"] not in all_skipped | |
| and any(f.startswith(f"{mod['projectID']}-{mod['fileID']}") for f in os.listdir("mods")) | |
| ) | |
| print(f" Mods already present, skipped download: {skipped_existing}") | |
| downloaded = sum( | |
| 1 for mod in files | |
| if mod["projectID"] not in all_skipped | |
| and any(f.startswith(f"{mod['projectID']}-") for f in os.listdir("mods")) | |
| ) | |
| print(f" Total mods in folder: {downloaded}") | |
| print(f"\nNext steps:") | |
| print(f" 1. java -jar neoforge-{neoforge_version}-installer.jar --installServer") | |
| print(f" 2. Copy your overrides/ folder contents into the server root") | |
| print(f" 3. Run the server via the generated run.sh / run.bat") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment