66import shutil
77import sys
88import tempfile
9+ import threading
910import zipfile
10- from typing import Optional , Tuple
11+ from concurrent .futures import ThreadPoolExecutor , as_completed
12+ from typing import Dict , Optional , Tuple
1113from urllib .parse import urlparse
1214
1315import 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+
3553def 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