Last active
February 21, 2022 23:01
-
-
Save mat-1/03f332170fb3f06eeb3a1df981720e9a to your computer and use it in GitHub Desktop.
Put this in your mods folder and run it to automatically update your Fabric mods.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
try: | |
from simplejson.errors import JSONDecodeError | |
except: | |
from json import JSONDecodeError | |
import traceback | |
import requests | |
import hashlib | |
import shutil | |
import array | |
import time | |
import sys | |
import os | |
def sha1_hash(mod_file_name): | |
with open(mod_file_name, 'rb') as f: | |
mod_file_buffer = f.read() | |
sha1 = hashlib.sha1() | |
sha1.update(mod_file_buffer) | |
return sha1.hexdigest() | |
def murmur2_hash(mod_file_name): | |
with open(mod_file_name, 'rb') as f: | |
input = bytearray() | |
for b in f.read(): | |
if b not in { 9, 10, 13, 32 }: | |
input.append(b) | |
seed = 1 | |
l = len(input) | |
m = 0x5bd1e995 | |
h = seed ^ l | |
x = l % 4 | |
o = l - x | |
for k in array.array('I', input[:o]): | |
k = (k * m) & 0xFFFFFFFF | |
h = (((k ^ (k >> 24)) * m) ^ (h * m)) & 0xFFFFFFFF | |
if x > 0: | |
if x > 2: | |
h ^= input[o+2] << 16 | |
if x > 1: | |
h ^= input[o+1] << 8 | |
h = ((h ^ input[o]) * m) & 0xFFFFFFFF | |
h = ((h ^ (h >> 13)) * m) & 0xFFFFFFFF | |
return (h ^ (h >> 15)) | |
# auto update the auto updater | |
new_code_contents = requests.get('https://gist.githubusercontent.com/mat-1/03f332170fb3f06eeb3a1df981720e9a/raw/update.py?' + str(time.time())).text | |
with open(__file__, 'r') as f: | |
current_code_contents = f.read() | |
if new_code_contents != current_code_contents: | |
user_update_response = input('New version of update.py detected! Type anything that starts with "y" and enter to update: ') | |
if user_update_response and user_update_response.strip()[0] == 'y': | |
with open(__file__, 'w') as f: | |
f.write(new_code_contents) | |
exec(new_code_contents) | |
exit() | |
protocol_versions = requests.get('https://raw.githubusercontent.com/PrismarineJS/minecraft-data/master/data/pc/common/protocolVersions.json').json() | |
default_minecraft_version = '' | |
try: | |
# read their options.txt to get the minecraft version they're using | |
options_txt_location = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'options.txt') | |
with open(options_txt_location, 'r') as f: | |
options_txt_lines = f.read().splitlines() | |
options_txt = {} | |
for line in options_txt_lines: | |
key, value = line.split(':', 1) | |
options_txt[key] = value | |
if 'version' not in options_txt: raise FileNotFoundError | |
for protocol_version in protocol_versions: | |
if str(protocol_version['dataVersion']) == options_txt['version']: | |
default_minecraft_version = protocol_version['minecraftVersion'] | |
break | |
except FileNotFoundError: | |
# if they don't have an options.txt, just go with the most recent release of Minecraft that's not a snapshot | |
for protocol_version in protocol_versions: | |
if protocol_version['version'] < 1000000000: | |
default_minecraft_version = protocol_version['minecraftVersion'] | |
break | |
minecraft_version: str = input(f'Enter your desired Minecraft version (don\'t input anything to use {default_minecraft_version}): ') or default_minecraft_version | |
def modrinth_find_latest_download(mod_id, previous_hash): | |
mod_versions = requests.get(f'https://api.modrinth.com/api/v1/mod/{mod_id}/version').json() | |
previous_mod_version = None | |
best_mod_version = None | |
for mod_version in mod_versions: | |
if len(mod_version['files']) == 0: continue | |
if mod_version['files'][-1]['hashes']['sha1'] == previous_hash: | |
previous_mod_version = mod_version | |
if not minecraft_version in mod_version['game_versions']: continue | |
if not 'fabric' in mod_version['loaders']: continue | |
if not best_mod_version: | |
best_mod_version = mod_version | |
if best_mod_version: | |
return previous_mod_version, best_mod_version | |
return None, None | |
def curseforge_find_latest_download(mod_id, previous_hash): | |
mod_data = requests.get( | |
f'https://addons-ecs.forgesvc.net/api/v2/addon/{mod_id}', | |
headers={ 'User-Agent': 'Mozilla/5.0 update.py' } | |
).json() | |
mod_files = requests.get( | |
f'https://addons-ecs.forgesvc.net/api/v2/addon/{mod_id}/files', | |
headers={ 'User-Agent': 'Mozilla/5.0 update.py' } | |
).json() | |
best_version = None | |
previous_version = None | |
for mod_version in sorted(mod_files, key=lambda i: i['id'], reverse=True): | |
if mod_version['packageFingerprint'] == previous_hash: | |
previous_version = mod_version | |
if minecraft_version not in mod_version['gameVersion']: continue | |
is_fabric = False | |
for module in mod_version['modules']: | |
if module['foldername'] == 'fabric.mod.json': | |
is_fabric = True | |
break | |
if not is_fabric: continue | |
if not best_version: | |
best_version = mod_version | |
if best_version: | |
return str(mod_data['name']), previous_version, best_version | |
return None, None, None | |
class ModUpdateResponse: | |
def __init__( | |
self, | |
status_code, | |
mod_name, | |
mod_file_name, | |
mod_download_url, | |
old_version, | |
new_version | |
): | |
self.status_code = status_code | |
self.mod_name = mod_name | |
self.mod_file_name = mod_file_name | |
self.mod_download_url = mod_download_url | |
self.old_version = old_version | |
self.new_version = new_version | |
def guess_version_from_filename(file_name): | |
file_name = file_name[:-4] | |
split_file_name = file_name.split('-') | |
if split_file_name[-1] != 'fabric': | |
return split_file_name[-1] | |
else: | |
return split_file_name[-2] | |
def find_latest_download(mod_file_name): | |
mod_file_hash_sha1 = sha1_hash(mod_file_name) | |
mod_file_hash_murmur2 = murmur2_hash(mod_file_name) | |
try: | |
modrinth_version = requests.get(f'https://api.modrinth.com/v2/version_file/{mod_file_hash_sha1}?algorithm=sha1').json() | |
previous_modrinth_download, latest_modrinth_download = modrinth_find_latest_download(modrinth_version['id'], previous_hash=mod_file_hash_sha1) | |
if latest_modrinth_download: | |
mod_name = requests.get('https://api.modrinth.com/v2/project/' + modrinth_version['id']).json()['title'] | |
latest_modrinth_download_hash = latest_modrinth_download['files'][-1]['hashes']['sha1'] | |
latest_modrinth_download_url = latest_modrinth_download['files'][-1]['url'] | |
latest_modrinth_download_file_name = latest_modrinth_download['files'][-1]['filename'] | |
previous_modrinth_download_version_number = previous_modrinth_download['version_number'] if previous_modrinth_download else '???' | |
latest_modrinth_download_version_number = latest_modrinth_download['version_number'] | |
if latest_modrinth_download_hash == mod_file_hash_sha1: | |
return ModUpdateResponse('already_latest', mod_name, latest_modrinth_download_file_name, latest_modrinth_download_url, previous_modrinth_download_version_number, latest_modrinth_download_version_number) | |
else: | |
return ModUpdateResponse('updated', mod_name, latest_modrinth_download_file_name, latest_modrinth_download_url, previous_modrinth_download_version_number, latest_modrinth_download_version_number) | |
except JSONDecodeError: | |
pass | |
curseforge_version = requests.post('https://addons-ecs.forgesvc.net/api/v2/fingerprint', headers={ | |
'Accept': 'application/json', | |
'Content-Type': 'application/json', | |
'User-Agent': 'Mozilla/5.0 update.py' | |
}, json=[mod_file_hash_murmur2]) | |
curseforge_match = curseforge_version.json() | |
if len(curseforge_match['exactMatches']) > 0: | |
mod_id = curseforge_match['exactMatches'][0]['id'] | |
mod_name, previous_curseforge_download, latest_curseforge_download = curseforge_find_latest_download(mod_id, previous_hash=mod_file_hash_murmur2) | |
if latest_curseforge_download: | |
latest_curseforge_download_hash = latest_curseforge_download['packageFingerprint'] | |
latest_curseforge_download_url = latest_curseforge_download['downloadUrl'] | |
latest_curseforge_download_file_name = latest_curseforge_download['fileName'] | |
previous_curseforge_download_version_number = guess_version_from_filename(previous_curseforge_download['fileName']) if previous_curseforge_download else '???' | |
latest_curseforge_download_version_number = guess_version_from_filename(latest_curseforge_download['fileName']) | |
if latest_curseforge_download_hash == mod_file_hash_murmur2: | |
return ModUpdateResponse('already_latest', mod_name, latest_curseforge_download_file_name, latest_curseforge_download_url, previous_curseforge_download_version_number, latest_curseforge_download_version_number) | |
else: | |
return ModUpdateResponse('updated', mod_name, latest_curseforge_download_file_name, latest_curseforge_download_url, previous_curseforge_download_version_number, latest_curseforge_download_version_number) | |
return ModUpdateResponse('not_found', None, None, None, None, None) | |
mods_dir = os.path.dirname(os.path.realpath(__file__)) | |
# make a backup of the mods first | |
try: shutil.rmtree(os.path.join(mods_dir, 'backup-mods')) | |
except FileNotFoundError: pass | |
os.makedirs(os.path.join(mods_dir, 'backup-mods')) | |
mod_file_names = os.listdir(mods_dir) | |
for mod_file_name in mod_file_names: | |
if mod_file_name.endswith('.jar'): | |
with open(os.path.join(mods_dir, mod_file_name), 'rb') as f: | |
with open(os.path.join(mods_dir, 'backup-mods/' + mod_file_name), 'wb') as f2: | |
f2.write(f.read()) | |
os.system('') | |
def load_backup(): | |
try: | |
for mod_file_name in os.listdir(mods_dir): | |
if mod_file_name.endswith('.jar'): | |
os.remove(os.path.join(mods_dir, mod_file_name)) | |
for mod_file_name in os.listdir(os.path.join(mods_dir, 'backup-mods')): | |
with open(os.path.join(mods_dir, f'backup-mods/{mod_file_name}'), 'rb') as f: | |
with open(os.path.join(mods_dir, mod_file_name), 'wb') as f2: | |
f2.write(f.read()) | |
except KeyboardInterrupt: | |
return load_backup() | |
while True: | |
try: | |
shutil.rmtree(os.path.join(mods_dir, 'backup-mods')) | |
return | |
except KeyboardInterrupt: pass | |
try: | |
no_mods_updated = True | |
for mod_file_name in mod_file_names: | |
if mod_file_name.endswith('.jar'): | |
update_response = find_latest_download(os.path.join(mods_dir, mod_file_name)) | |
if update_response.status_code == 'already_latest': | |
print(f'\033[90m{update_response.mod_name} is already on the latest version. ({update_response.old_version})\033[0m') | |
elif update_response.status_code == 'updated': | |
updated_mod_contents = requests.get(str(update_response.mod_download_url)).content | |
os.remove(os.path.join(mods_dir, mod_file_name)) | |
with open(os.path.join(mods_dir, str(update_response.mod_file_name)), 'wb') as f: | |
f.write(updated_mod_contents) | |
print(f'\033[92m{update_response.mod_name} has been updated!\033[0m ({update_response.old_version} -> {update_response.new_version})') | |
no_mods_updated = False | |
else: | |
print(f'\033[31mCouldn\'t find suitable version for {mod_file_name} on Modrinth or Curseforge.\033[0m') | |
if no_mods_updated: | |
input('No mods updated. Press enter to finish.\n') | |
else: | |
input('Finished updating mods! Press enter to finish or Ctrl+C to undo.\n') | |
except BaseException as e: | |
if isinstance(e, KeyboardInterrupt): | |
print('Loading backup...', end=' ', flush=True) | |
load_backup() | |
try: | |
input('Backup loaded.\nPress enter to finish.\n') | |
except (KeyboardInterrupt, EOFError): | |
pass | |
else: | |
traceback.print_exception(type(e), e, e.__traceback__, file=sys.stderr) | |
print('Something went wrong (traceback above), loading backup.') | |
load_backup() | |
try: | |
input('Backup loaded, put a comment on the Gist to report this bug. https://gist.github.com/mat-1/03f332170fb3f06eeb3a1df981720e9a\nPress enter to finish.\n') | |
except (KeyboardInterrupt, EOFError): | |
pass | |
# we no longer need this file | |
try: shutil.rmtree(os.path.join(mods_dir, 'backup-mods')) | |
except FileNotFoundError: pass |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment