Skip to content

Instantly share code, notes, and snippets.

@dark-swordsman
Created April 12, 2026 06:41
Show Gist options
  • Select an option

  • Save dark-swordsman/c0f631fd3ee5a3dc166daec4482a2825 to your computer and use it in GitHub Desktop.

Select an option

Save dark-swordsman/c0f631fd3ee5a3dc166daec4482a2825 to your computer and use it in GitHub Desktop.
CurseForge Server Mods Downloader
# -*- 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