Skip to content
This repository was archived by the owner on Apr 12, 2026. It is now read-only.

Commit 62eb4f0

Browse files
committed
feat: speed up modpack processing with multithreading and session management
1 parent a4d09f6 commit 62eb4f0

1 file changed

Lines changed: 98 additions & 46 deletions

File tree

server_pack_builder.py

Lines changed: 98 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
import shutil
77
import sys
88
import tempfile
9+
import threading
910
import zipfile
10-
from typing import Optional, Tuple
11+
from concurrent.futures import ThreadPoolExecutor, as_completed
12+
from typing import Dict, Optional, Tuple
1113
from urllib.parse import urlparse
1214

1315
import requests
@@ -32,6 +34,22 @@ def setup_logging(verbose: bool):
3234
)
3335

3436

37+
def get_default_worker_count() -> int:
38+
cpu_count = os.cpu_count() or 1
39+
return max(4, min(32, cpu_count * 4))
40+
41+
42+
_thread_local = threading.local()
43+
44+
45+
def get_requests_session() -> requests.Session:
46+
session = getattr(_thread_local, "session", None)
47+
if session is None:
48+
session = requests.Session()
49+
_thread_local.session = session
50+
return session
51+
52+
3553
def is_fabric_client_only(jar_path: str) -> Tuple[Optional[bool], str]:
3654
"""
3755
Checks if a jar is a Fabric mod and if it is client-side only.
@@ -148,13 +166,27 @@ def process_local_modpack(source: str, dest: str, dry_run: bool):
148166

149167
logging.info(f"Scanning {total_mods} JAR files in '{source}'...")
150168

151-
for filename in files:
169+
def analyze_jar(index: int, filename: str) -> Tuple[int, str, str, bool, str, str]:
152170
file_path = os.path.join(source, filename)
153-
processed += 1
154-
155171
is_client, mod_type, reason = check_jar_sidedness(file_path)
172+
return index, filename, file_path, is_client, mod_type, reason
173+
174+
max_workers = get_default_worker_count()
175+
results: Dict[int, Tuple[str, str, bool, str, str]] = {}
176+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
177+
futures = [
178+
executor.submit(analyze_jar, index, filename)
179+
for index, filename in enumerate(files)
180+
]
181+
182+
for future in as_completed(futures):
183+
index, filename, file_path, is_client, mod_type, reason = future.result()
184+
results[index] = (filename, file_path, is_client, mod_type, reason)
185+
186+
for index in range(len(files)):
187+
filename, file_path, is_client, mod_type, reason = results[index]
188+
processed += 1
156189

157-
# Action based on sidedness
158190
if is_client:
159191
logging.info(f"[SKIP] {filename} ({mod_type}): {reason}")
160192
skipped += 1
@@ -210,7 +242,8 @@ def process_modrinth_pack(modrinth_url: str, output_file: Optional[str], dry_run
210242

211243
try:
212244
# Get Project Info
213-
project_resp = requests.get(f"https://api.modrinth.com/v2/project/{slug}")
245+
session = get_requests_session()
246+
project_resp = session.get(f"https://api.modrinth.com/v2/project/{slug}")
214247
if project_resp.status_code != 200:
215248
logging.error(
216249
f"Failed to fetch project info: {project_resp.status_code} {project_resp.text}"
@@ -228,14 +261,10 @@ def process_modrinth_pack(modrinth_url: str, output_file: Optional[str], dry_run
228261

229262
# Get Version Info
230263
if version_id:
231-
version_resp = requests.get(
232-
f"https://api.modrinth.com/v2/version/{version_id}"
233-
)
264+
version_resp = session.get(f"https://api.modrinth.com/v2/version/{version_id}")
234265
else:
235266
# Get versions list and pick latest
236-
version_resp = requests.get(
237-
f"https://api.modrinth.com/v2/project/{slug}/version"
238-
)
267+
version_resp = session.get(f"https://api.modrinth.com/v2/project/{slug}/version")
239268

240269
if version_resp.status_code != 200:
241270
logging.error(
@@ -276,10 +305,10 @@ def process_modrinth_pack(modrinth_url: str, output_file: Optional[str], dry_run
276305

277306
logging.info(f"Downloading modpack from {mrpack_url}...")
278307
if not dry_run:
279-
with requests.get(mrpack_url, stream=True) as r:
308+
with session.get(mrpack_url, stream=True) as r:
280309
r.raise_for_status()
281310
with open(pack_path, "wb") as f:
282-
for chunk in r.iter_content(chunk_size=8192):
311+
for chunk in r.iter_content(chunk_size=65536):
283312
f.write(chunk)
284313
else:
285314
# Mock file for dry run if checking structure logic (but we need real file to extract index)
@@ -289,10 +318,10 @@ def process_modrinth_pack(modrinth_url: str, output_file: Optional[str], dry_run
289318
# Dry run usually means "don't produce final output/change system state".
290319
# We will download to temp, analyze, but not write final output.
291320
logging.info("(Dry Run) Downloading for analysis...")
292-
with requests.get(mrpack_url, stream=True) as r:
321+
with session.get(mrpack_url, stream=True) as r:
293322
r.raise_for_status()
294323
with open(pack_path, "wb") as f:
295-
for chunk in r.iter_content(chunk_size=8192):
324+
for chunk in r.iter_content(chunk_size=65536):
296325
f.write(chunk)
297326

298327
# Extract modrinth.index.json
@@ -330,43 +359,72 @@ def process_modrinth_pack(modrinth_url: str, output_file: Optional[str], dry_run
330359
kept = 0
331360
skipped = 0
332361

333-
for mod_entry in files_list:
334-
processed += 1
362+
def check_mod_entry(
363+
index: int, mod_entry: dict
364+
) -> Tuple[int, dict, bool, str, str, str]:
335365
path = mod_entry.get("path", "")
336-
337-
# Only check mods (usually in mods/ directory)
338-
# Some packs might put things elsewhere, but standard is mods/
339-
if not path.startswith("mods/"):
340-
# Keep non-mod files (resource packs etc might be here? usually they are, but safe to keep?)
341-
# If it's a resource pack, it's client only usually.
342-
# Safe bet: If it ends in .jar, check it. If not, keep it?
343-
# Let's assume everything in 'files' is a mod or resource.
344-
# We will download everything to check.
345-
pass
346-
347-
download_url = mod_entry["downloads"][0] # Primary download
348366
filename = os.path.basename(path)
349-
temp_jar_path = os.path.join(temp_dir, filename)
367+
download_url = mod_entry["downloads"][0]
350368

351-
# Download mod JAR
352369
logging.debug(f"Checking {filename}...")
370+
temp_handle, temp_jar_path = tempfile.mkstemp(
371+
suffix=".jar", dir=temp_dir
372+
)
373+
os.close(temp_handle)
374+
353375
try:
354-
with requests.get(download_url, stream=True) as r:
376+
session = get_requests_session()
377+
with session.get(download_url, stream=True) as r:
355378
r.raise_for_status()
356379
with open(temp_jar_path, "wb") as f:
357-
for chunk in r.iter_content(chunk_size=8192):
380+
for chunk in r.iter_content(chunk_size=65536):
358381
f.write(chunk)
359382
except Exception as e:
360-
logging.warning(
361-
f"Failed to download {filename}: {e}. Skipping check, assuming kept."
383+
try:
384+
os.remove(temp_jar_path)
385+
except OSError:
386+
pass
387+
return (
388+
index,
389+
mod_entry,
390+
False,
391+
"Unknown",
392+
f"Failed to download: {e}",
393+
filename,
362394
)
363-
new_files_list.append(mod_entry)
364-
kept += 1
365-
continue
366395

367-
# Check sidedness
368396
is_client, mod_type, reason = check_jar_sidedness(temp_jar_path)
369397

398+
try:
399+
os.remove(temp_jar_path)
400+
except OSError:
401+
pass
402+
403+
return index, mod_entry, is_client, mod_type, reason, filename
404+
405+
max_workers = get_default_worker_count()
406+
results: Dict[int, Tuple[dict, bool, str, str, str]] = {}
407+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
408+
futures = [
409+
executor.submit(check_mod_entry, index, mod_entry)
410+
for index, mod_entry in enumerate(files_list)
411+
]
412+
413+
for future in as_completed(futures):
414+
(
415+
index,
416+
mod_entry,
417+
is_client,
418+
mod_type,
419+
reason,
420+
filename,
421+
) = future.result()
422+
results[index] = (mod_entry, is_client, mod_type, reason, filename)
423+
424+
for index in range(len(files_list)):
425+
processed += 1
426+
mod_entry, is_client, mod_type, reason, filename = results[index]
427+
370428
if is_client:
371429
logging.info(f"[SKIP] {filename} ({mod_type}): {reason}")
372430
skipped += 1
@@ -375,12 +433,6 @@ def process_modrinth_pack(modrinth_url: str, output_file: Optional[str], dry_run
375433
new_files_list.append(mod_entry)
376434
kept += 1
377435

378-
# Remove temp jar to save space
379-
try:
380-
os.remove(temp_jar_path)
381-
except OSError:
382-
pass
383-
384436
# Write new index
385437
index_data["files"] = new_files_list
386438
index_data["name"] = f"{index_data.get('name', project_title)} (Server)"

0 commit comments

Comments
 (0)