2 # CurseForge modpack downloader
3 # This program is an alternative to the Twitch client, written for Linux users,
4 # so that they can install Minecraft modpacks from CurseForge.
5 # This tool requires that the user download the pack zip from CurseForge. It
6 # will then generate a complete modpack directory that can be imported into
7 # a launcher of the user's choice.
9 # Please see the included README file for more info.
24 from concurrent
.futures
import ThreadPoolExecutor
25 from zipfile
import ZipFile
28 API_URL
= 'https://api.modpacks.ch/public'
31 SLEEP_SECONDS
= WORKERS
/ REQUESTS_PER_SEC
34 def main(zipfile
, *, packdata_dir
, mc_dir
=None):
36 packname
= os
.path
.splitext(zipfile
)[0]
37 packname
= os
.path
.basename(packname
)
40 if not os
.path
.isdir('packs/'):
42 mc_dir
= 'packs/' + packname
43 # Generate minecraft environment
44 print("Output directory is '%s'" % mc_dir
)
45 if os
.path
.isdir(mc_dir
):
46 if os
.listdir(mc_dir
):
47 print("Error: Output directory already exists and is not empty")
50 print("Output directory exists (and is empty)")
52 print("Creating output directory")
56 print("Extracting %s" % zipfile
)
57 with
ZipFile(zipfile
, 'r') as zip:
58 zip.extractall(packdata_dir
)
61 with
open(packdata_dir
+ '/manifest.json', 'r') as mf
:
62 manifest
= json
.load(mf
)
63 except (json
.JsonDecodeError
, OSError) as e
:
64 print("Manifest file not found or was corrupted.")
68 ml_message
= 'You then need to install: '
69 for modloader
in manifest
['minecraft']['modLoaders']:
70 ml_message
= ml_message
+ modloader
['id'] + " "
73 print("Downloading mods")
74 if not os
.path
.isdir('.modcache'):
77 # if not os.path.isdir('node_modules'):
78 # print("Installing NodeJS dependencies")
79 # subprocess.run(['npm', 'install'])
80 # subprocess.run(['node', 'mod_download.js', packdata_dir + '/manifest.json', '.modcache', packdata_dir + '/mods.json'])
82 mods
, manual_downloads
= download_all_mods(packdata_dir
+ '/manifest.json', '.modcache')
85 os
.mkdir(mc_dir
+ '/mods')
86 os
.mkdir(mc_dir
+ '/resources')
88 # TODO detect texture packs
92 # if type == 'mc-mods':
93 # modfile = mc_dir + '/mods/' + os.path.basename(jar)
94 # if not os.path.exists(modfile):
95 # cp_safe(os.path.abspath(jar), modfile)
96 # elif type == 'texture-packs':
97 # print("Extracting texture pack %s" % jar)
98 # with tempfile.TemporaryDirectory() as texpack_dir:
99 # with ZipFile(jar, 'r') as zip:
100 # zip.extractall(texpack_dir)
101 # for dir in os.listdir(texpack_dir + '/assets'):
102 # f = texpack_dir + '/assets/' + dir
103 # cp_safe(f, mc_dir + '/resources/' + dir)
105 # print("Unknown file type %s" % type)
109 override_dir
= packdata_dir
+ '/overrides/'
110 if os
.path
.exists(override_dir
):
111 print("Copying overrides")
112 for dir in os
.listdir(override_dir
):
114 cp_safe(override_dir
+ dir, mc_dir
+ '/' + dir)
117 print("Copying overrides [nothing to do]")
119 print("Done!\n\n\n\nThe modpack has been downloaded to: " + mc_dir
)
121 if len(manual_downloads
) > 0:
123 msg
+="====MANUAL DOWNLOAD REQUIRED====\n"
124 msg
+="The following mods failed to download\n"
125 msg
+="Please download them manually and place them in " + mc_dir
+ "/mods\n"
126 for url
, resp
in manual_downloads
:
129 with
open(mc_dir
+ '/MANUAL-DOWNLOAD-README.txt', 'w') as f
:
136 def get_json(session
, url
, logtag
):
138 print(logtag
+ "GET (json) " + url
)
139 for tout
in [4,5,10,20,30]:
141 r
= session
.get(url
, timeout
=tout
)
144 except requests
.Timeout
as e
:
145 print(logtag
+ "timeout %02d %s" % (tout
, url
))
148 print(logtag
+ "GET (json, long timeout) " + url
)
149 r
= session
.get(url
, timeout
=120)
151 except requests
.Timeout
as e
:
152 print(logtag
+ "timeout")
153 traceback
.print_exc()
154 print(logtag
+ "Error timeout trying to access %s" % url
)
157 return json
.loads(r
.text
)
159 def fetch_mod(session
, f
, out_dir
, logtag
, attempt
):
160 rnd
= random
.random() * SLEEP_SECONDS
165 project_info
= get_json(session
, API_URL
+ ('/mod/%d' % pid
), logtag
)
166 if project_info
is None:
167 print(logtag
+ "fetch failed")
170 file_type
= "mc-mods"
171 info
= [x
for x
in project_info
["versions"] if x
["id"] == fid
]
174 print(logtag
+ "Could not find mod jar for pid:%s fid:%s, got %s results" % (pid
, fid
, len(info
)))
175 return (f
, 'dist-error' if attempt
== "retry" else 'error', project_info
)
180 sha1_expected
= info
['sha1'].lower()
181 out_file
= out_dir
+ '/' + fn
183 if os
.path
.exists(out_file
):
184 if os
.path
.getsize(out_file
) == info
['size'] and sha1_expected
== sha1(out_file
):
185 print(logtag
+ "%s OK cached" % fn
)
186 return (out_file
, file_type
)
188 status
= download(dl
, out_file
, session
=session
, progress
=False)
189 time
.sleep(SLEEP_SECONDS
- rnd
)
190 if sha1_expected
!= sha1(out_file
):
191 print(logtag
+ "download failed (SHA1 mismatch!)" % status
)
194 print(logtag
+ "download failed (error %d)" % status
)
196 print(logtag
+ "%s OK downloaded" % fn
)
197 return (out_file
, file_type
)
199 print(logtag
+ "download failed (exception)")
200 traceback
.print_exc()
201 return (f
, 'dist-error' if attempt
== "retry" else 'error', project_info
)
203 async def download_mods_async(manifest
, out_dir
):
204 with
ThreadPoolExecutor(max_workers
=WORKERS
) as executor
, \
205 requests
.Session() as session
:
206 loop
= asyncio
.get_event_loop()
208 maxn
= len(manifest
['files'])
210 print("Downloading %s mods" % maxn
)
211 for n
, f
in enumerate(manifest
['files']):
212 logtag
= "[" + str(n
+1) + "/" + str(maxn
) + "] "
213 task
= loop
.run_in_executor(executor
, fetch_mod
, *(session
, f
, out_dir
, logtag
, "first attempt"))
217 manual_downloads
= []
218 while len(tasks
) > 0:
221 for resp
in await asyncio
.gather(*tasks
):
222 if resp
[1] == 'error':
223 print("failed to fetch %s, retrying later" % resp
[0])
224 retry_tasks
.append(resp
[0])
225 elif resp
[1] == 'dist-error':
227 manual_dl_url
= resp
[2]['links'][0]['link'] + '/download/' + str(resp
[0]['fileID'])
228 manual_downloads
.append((manual_dl_url
, resp
))
229 # add to jars list so that the file gets linked
230 jars
.append(resp
[3:])
235 if len(retry_tasks
) > 0:
238 for f
in retry_tasks
:
239 tasks
.append(loop
.run_in_executor(executor
, fetch_mod
, *(session
, f
, out_dir
, logtag
, "retry")))
240 return jars
, manual_downloads
243 def download_all_mods(manifest_json
, mods_dir
):
245 with
open(manifest_json
, 'r') as f
:
246 manifest
= json
.load(f
)
248 loop
= asyncio
.get_event_loop()
249 future
= asyncio
.ensure_future(download_mods_async(manifest
, mods_dir
))
250 loop
.run_until_complete(future
)
251 return future
.result()
253 def status_bar(text
, progress
, bar_width
=0.5, show_percent
=True, borders
='[]', progress_ch
='#', space_ch
=' '):
254 ansi_el
= '\x1b[K\r' # escape code to clear the rest of the line plus carriage return
255 term_width
= shutil
.get_terminal_size().columns
259 bar_width_c
= max(int(term_width
* bar_width
), 4)
260 text_width
= min(term_width
- bar_width_c
- 6, len(text
)) # subract 4 characters for percentage and 2 spaces
261 text_part
= '' if (text_width
== 0) else text
[-text_width
:]
263 progress_c
= int(progress
* (bar_width_c
- 2))
264 remaining_c
= bar_width_c
- 2 - progress_c
265 padding_c
= max(0, term_width
- bar_width_c
- text_width
- 6)
267 bar
= borders
[0] + progress_ch
* progress_c
+ space_ch
* remaining_c
+ borders
[1]
268 pad
= ' ' * padding_c
269 print("%s %s%3.0f%% %s" % (text_part
, pad
, (progress
* 100), bar
), end
=ansi_el
)
271 def download(url
, dest
, progress
=False, session
=None):
273 if session
is not None:
274 r
= session
.get(url
, stream
=True)
276 r
= requests
.get(url
, stream
=True)
277 size
= int(r
.headers
['Content-Length'])
279 if r
.status_code
!= 200:
282 with
open(dest
, 'wb') as f
:
285 for chunk
in r
.iter_content(1048576):
288 status_bar(url
, n
/ size
)
291 except requests
.RequestException
:
301 def cp_safe(src
, dst
):
302 if os
.path
.exists(dst
):
303 raise FileExistsError("Cannot copy '%s' -> '%s' because the destination already exists" % (src
, dst
))
304 if os
.path
.isdir(src
):
305 shutil
.copytree(src
, dst
)
307 shutil
.copyfile(src
, dst
)
311 with
open(src
, 'rb') as f
:
319 # And, of course, the main:
321 if __name__
== "__main__":
322 parser
= argparse
.ArgumentParser()
323 parser
.add_argument('zipfile')
324 parser
.add_argument('--outdir', dest
='outdir')
325 args
= parser
.parse_args(sys
.argv
[1:])
326 with tempfile
.TemporaryDirectory() as packdata_dir
:
327 main(args
.zipfile
, packdata_dir
=packdata_dir
, mc_dir
=args
.outdir
)