#!/usr/bin/env python3 import argparse import os import sys import hashlib import shutil import re import collections import urllib.parse import requests parser = argparse.ArgumentParser( description="A Simple Git-Based Modpack Manager", formatter_class=argparse.RawDescriptionHelpFormatter, epilog='''\ Available commands: install : Downloads mods listed in version.txt and populates the mods folder specified in pack-location.txt apply_updates : Using the urls in mods.txt, repopulates version.txt to reflect the most recent mod versions check_updates : Compares version.txt and mods.txt to see if any mods can be updated ''') parser.add_argument('command', nargs='?', default='install', help="The action to perform (default: install)") parser.add_argument('filename', nargs='?', default="mods.txt", help="Optional filename to specify latest mods (default: mods.txt)") parser.add_argument('--version-file', type=str, default="version.txt", help="Optional custom version file to download mods from (default: version.txt)") parser.add_argument('--pack-location', type=str, help="Optional custom modpack folder location (default: read from pack-location.txt)") parser.add_argument('--whitelist-file', type=str, default="whitelist.txt", help="Optional custom whitelist file that tells 'install' which files not to remove (default: whitelist.txt)") parser.add_argument("--game-version", type=str, default=None, help="The maximum game version to update mods to") ## loaded from version.txt VERSION = 0 def read_file(fil): strings = [] with open(fil) as f: for line in f: string = line.strip().split() if len(line) > 1 and line[0] != '#': # run strip on each element string = tuple(map(lambda x: x.strip(), string)) strings.append(string) return strings # Apply updates to the actual mod pack def install(args): print("Updating pack with version " + str(VERSION) + "...") print() # (fname, checksum, url) mods = read_file(args.version_file) names = [mod[0] for mod in mods] # whitelist client mods (e.g. optifine) names += [line[0] for line in read_file(args.whitelist_file)] i = 0 for mod in mods: mod_path = os.path.join(args.pack_location, mod[0]) i += 1 if os.path.exists(mod_path) and os.path.isfile(mod_path) and \ hashlib.sha1(open(mod_path, 'rb').read()).hexdigest() == mod[1]: print("Skipping {mod[0]}, already up to date".format(mod=mod)) else: print('Installing {mod[0]} from {mod[2]}...'.format(mod=mod)) print(' ({i} of {x})'.format(i=i,x=len(mods)), end='\r') download_obj = requests.get(mod[2], stream=True) with open(mod_path, "wb") as write_file: shutil.copyfileobj(download_obj.raw, write_file) print("Done!" + " " * 8) print() print("Removing old mods...") for jar in os.listdir(args.pack_location): if jar not in names and os.path.splitext(jar)[1] == ".jar": os.remove(os.path.join(args.pack_location, jar)) print("Removing '{jar}'".format(jar=jar)) print() print("Finished installing mods!") # Using the latest urls, update downloads.txt to match and have the correct sha1 def apply_updates(args): if args.game_version is not None: version = tuple(int(x) for x in args.game_version.split('.')) else: version = (2, 0, 0) print("Populating version File...") mods = read_file(args.filename) print("Getting new versions of all mods...") ffx = firefox() with open(args.version_file, 'w') as f: f.write('# Format: \n') f.write("#VERSION " + str(VERSION + 1) + "\n") for mod in mods: print("Fetching {mod[0]}...".format(mod=mod)) if 'curseforge' in mod[1]: url = find_cdn(ffx, mod[1], version) else: url = requests.get(mod[1]).url if url is None: print('[!]Failed to fetch {mod[0]}!'.format(mod=mod)) continue resp = requests.get(url) hsh = hashlib.sha1(resp.content).hexdigest() f.write('{mod[0]} {hsh} {resp.url}\n'.format(mod=mod, hsh=hsh, resp=resp)) ffx.close() print() print("Done!") print("Updates applied to {args.version_file}".format(args=args)) print("New pack version is " + str(VERSION + 1)) print("[!] No mods were installed. To update your mods folder, run 'update.py install'") # Find if any updates are available def check_updates(args): if args.game_version is not None: version = tuple(int(x) for x in args.game_version.split('.')) else: version = (2, 0, 0) print("Checking for updates to version " + str(VERSION) + "...") latest = read_file(args.filename) old = read_file(args.version_file) old_urls = [mod[2] for mod in old] num_updates = 0 print("Checking updates...") ffx = firefox() for mod in latest: print("Checking for updates to {mod[0]}...".format(mod=mod), end="") sys.stdout.flush() # takes care of line-buffered terminals if 'curseforge' in mod[1]: url = find_cdn(ffx, mod[1], version) else: url = requests.get(mod[1]).url if url in old_urls: print(" No updates") else: print(" Found update: " + url.split('/')[-1]) num_updates += 1 ffx.close() print("Finished checking for updates. {num} mods can be updated".format(num=num_updates)) if num_updates >= 0: print("Run 'python update.py apply_updates' to create a new version with these updates applied.") def find_cdn(ffx, url, version): """ Given a mod home URL, finds the most up-to-date mod version compatible with the given game version. Returns the direct Forge CDN download URL """ #TODO filter mods by forge/fabric compatibility try: ffx.get(url + '/files/all') mod_versions = ffx.find_elements_by_class_name("listing")[0].find_elements_by_xpath("tbody/tr") # extract the table of files from the page row_info = collections.namedtuple("row_info", ["type", "filename", "cdn_id", "game_version"]) # create a custom tuple because data rows = [] for version_entry in mod_versions: # parse out the four fields that we use entry_cells = version_entry.find_elements_by_tag_name("td") release_type = entry_cells[0].text filename = urllib.parse.quote(entry_cells[1].find_elements_by_tag_name("a")[0].text) try: game_version = tuple([int(x) for x in entry_cells[4].find_element_by_class_name("mr-2").text.split(".")]) # get game version and convert to tuple except: game_version = (0, 0, 0) cdn_id = entry_cells[1].find_element_by_tag_name("a").get_property("href").split("/")[-1] rows.append(row_info(release_type, filename, cdn_id, game_version)) rows.sort(key=lambda x: x.game_version, reverse=True) best_row = next(x for x in rows if x.game_version <= version) return f'https://media.forgecdn.net/files/{best_row.cdn_id[:4]}/{best_row.cdn_id[4:]}/{best_row.filename}' except: import traceback; traceback.print_exc() return None def firefox(): print("Starting Selenium...") try: from selenium.webdriver import Firefox except: print("Applying updates requires the `selenium` package") sys.exit(0) return Firefox() COMMAND_MAP = { 'install': install, 'apply_updates': apply_updates, 'check_updates': check_updates, } if __name__ == "__main__": args = parser.parse_args() if not args.pack_location: # initialize from config with open("pack-location.txt", "r") as f: args.pack_location = f.read().strip() if not os.path.exists(args.version_file): print("Error: version file\"" + args.version_file + "\" does not exist.") parser.print_help() sys.exit(1) if not os.path.exists(args.pack_location): print("Error: mod folder \"" + args.pack_location + "\" does not exist.") parser.print_help() sys.exit(1) elif not os.path.isdir(args.pack_location): print("Error: mod folder \"" + args.pack_location + "\" is not actually a folder.") parser.print_help() sys.exit(1) if not os.path.exists(args.whitelist_file): print("Error: whitelist file \"" + args.whitelist_file + "\" does not exist.") sys.exit(1) if not (args.command in COMMAND_MAP): print("Error: command \"" + args.command + "\" does not exist") parser.print_help() sys.exit(1) # fetch version with open(args.version_file) as f: for line in f: if line.strip().split()[0] == "#VERSION": VERSION = int(line.strip().split()[1]) break # run the command COMMAND_MAP[args.command](args)