diff --git a/src/ModpackChangelogger.py b/src/ModpackChangelogger.py index 749373b..884daea 100644 --- a/src/ModpackChangelogger.py +++ b/src/ModpackChangelogger.py @@ -1,8 +1,9 @@ import argparse import logging +import constants from compare_packs import compare_packs from config_handler import load_config -from constants import VERSION +from extract_pack_data import mr_get_pack_data, cf_get_pack_data from get_json import get_json from out import markdown_out @@ -17,7 +18,7 @@ def setup_logging(debug): # Clear the log file with open('log.txt', 'w', encoding="utf-8") as f: f.write('') - + file_handler = logging.FileHandler('log.txt', encoding='utf-8') file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%H:%M')) @@ -26,7 +27,7 @@ def setup_logging(debug): logging.basicConfig(level=logging.INFO, handlers=[console_handler]) logger = logging.getLogger(__name__) - logger.debug("Version: %s", VERSION) + logger.debug("Version: %s", constants.VERSION) def parse_arguments(): @@ -56,13 +57,20 @@ def main(old_path, new_path, config_path, changelog_file, debug=False): old_json = get_json(old_path) new_json = get_json(new_path) + if constants.Modpacks_Format == 'modrinth': + old_ids, new_ids, old_info, new_info = mr_get_pack_data(old_json, new_json) + else: + old_ids, new_ids, old_info, new_info = cf_get_pack_data(old_json, new_json) + # Compare the packs - added, removed, updated = compare_packs(old_json, new_json, config) + added, removed, updated = compare_packs(old_ids, new_ids, old_info, new_info, config) + logger.debug("Added mods: %s\nRemoved mods:%s\nUpdated mods:%s\n", added, removed, updated) + # Print in a md doc markdown_out(added, removed, updated, config, changelog_file) if __name__ == "__main__": args = parse_arguments() if args.version: - print(f"ModpackChangelogger {VERSION}") + print(f"ModpackChangelogger {constants.VERSION}") main(args.old, args.new, args.config, args.file, args.debug) diff --git a/src/compare_packs.py b/src/compare_packs.py index 54f3a59..363c3c0 100644 --- a/src/compare_packs.py +++ b/src/compare_packs.py @@ -1,51 +1,25 @@ import asyncio import logging -import re from get_mod_names import get_mod_names -PATTERN = re.compile(r"(?<=data\/)[a-zA-Z0-9]{8}") - -def get_dependency_info(json): - loader = next((key for key in json['dependencies'].keys() if key != 'minecraft'), "Unknown") - return { - 'mc_version': json['dependencies']['minecraft'], - 'loader': loader, - 'loader_version': json['dependencies'][loader] - } - -def get_mod_urls(json): - return [download for url in json['files'] for download in url['downloads']] - -def extract_mod_ids(url_list): - return [PATTERN.search(str(url)).group(0) for url in url_list] - -def compare_packs(old_json, new_json, config): - old_info, new_info = get_dependency_info(old_json), get_dependency_info(new_json) - - new_urls, old_urls = set(get_mod_urls(new_json)), set(get_mod_urls(old_json)) - new_urls, old_urls = new_urls.difference(old_urls), old_urls.difference(new_urls) - - added_ids, removed_ids = set(extract_mod_ids(list(new_urls))), set(extract_mod_ids(list(old_urls))) - updated_ids = added_ids & removed_ids - added_ids -= updated_ids - removed_ids -= updated_ids +def compare_packs(old_ids, new_ids, old_info, new_info, config): + updated_ids = old_ids & new_ids + added_ids = new_ids - old_ids + removed_ids = old_ids - new_ids # Remove a category if disabled in config - if not config['check']['added_mods']: - added_ids = set() - if not config['check']['removed_mods']: - removed_ids = set() - if not config['check']['updated_mods']: - updated_ids = set() + added_ids = added_ids if config['check']['added_mods'] else set() + removed_ids = removed_ids if config['check']['removed_mods'] else set() + updated_ids = updated_ids if config['check']['updated_mods'] else set() added_mods, removed_mods, updated_mods = asyncio.run(get_mod_names(added_ids, removed_ids, updated_ids)) - added_mods = sorted(mod for mod in added_mods if mod is not None) - removed_mods = sorted(mod for mod in removed_mods if mod is not None) - updated_mods = sorted(mod for mod in updated_mods if mod is not None) + added_mods = sorted(mod for mod in added_mods if mod) + removed_mods = sorted(mod for mod in removed_mods if mod) + updated_mods = sorted(mod for mod in updated_mods if mod) if config['check']['loader']: - if old_info['loader'] != new_info['loader']: + if old_info['loader'] != new_info['loader']: added_mods.append(f"{new_info['loader']} (mod loader)") removed_mods.append(f"{old_info['loader']} (mod loader)") logging.debug("Loader change detected: %s, new loader: %s", old_info['loader'], new_info['loader']) @@ -57,6 +31,4 @@ def compare_packs(old_json, new_json, config): updated_mods.append(f"Minecraft version {new_info['mc_version']}") logging.debug("Minecraft version change detected: %s, new version: %s", old_info['mc_version'], new_info['mc_version']) - logging.debug("Added mods: %a\nRemoved mods: %a\nUpdated mods: %a", added_mods, removed_mods, updated_mods) - - return added_mods, removed_mods, updated_mods \ No newline at end of file + return added_mods, removed_mods, updated_mods diff --git a/src/constants.py b/src/constants.py index 5b1884a..58df990 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,5 +1,7 @@ # This file contains some hardcoded values, do not edit it directly if you don't know what you are doing. +# Mod ecosistem used (modrinth or curseforge) +Modpacks_Format = None # Version number VERSION = "0.2.0" # Default config @@ -16,5 +18,16 @@ }, } # Networking -HEADERS = {'User-Agent':f"TheBossMagnus/ModpackChangelogger/{VERSION} (thebossmagnus@proton.me)"} -MODRINTH_API_URL = "https://api.modrinth.com/v2" +MR_HEADERS = {'User-Agent':f"TheBossMagnus/ModpackChangelogger/{VERSION} (thebossmagnus@proton.me)"} +MR_API_URL = "https://api.modrinth.com/v2/projects?ids=" + +# DO NOT USE THIS KEY FOR YOUR OWN PROJECT/FORKS +CF_KEY = '$2a$10$GiT8VjJE8VJpcK68Wlz6aeJ5CPAZcRuTBcGuys8XtX5hGC87sIgku' +# You can get your own key at https://docs.curseforge.com + +CF_HEADERS = { + 'x-api-key': CF_KEY, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } +CF_API_URL = "https://api.curseforge.com/v1/mods" diff --git a/src/extract_pack_data.py b/src/extract_pack_data.py new file mode 100644 index 0000000..9bc4f7c --- /dev/null +++ b/src/extract_pack_data.py @@ -0,0 +1,52 @@ +import re + +def mr_get_pack_data(old_json, new_json): + PATTERN = re.compile(r"(?<=data\/)[a-zA-Z0-9]{8}") + + def get_dependency_info(json): + loader = next((key for key in json['dependencies'].keys() if key != 'minecraft'), "Unknown") + return { + 'mc_version': json['dependencies']['minecraft'], + 'loader': loader, + 'loader_version': json['dependencies'][loader] + } + + def get_mod_urls(json): + return [download for url in json['files'] for download in url['downloads']] + + def extract_mod_ids(url_list): + return [PATTERN.search(str(url)).group(0) for url in url_list] + + old_info, new_info = get_dependency_info(old_json), get_dependency_info(new_json) + new_urls, old_urls = set(get_mod_urls(new_json)), set(get_mod_urls(old_json)) + # remove urls that are in both packs (not added nor removed nor updated) + common_urls = new_urls & old_urls + new_urls -= common_urls + old_urls -= common_urls + + old_ids, new_ids = set(extract_mod_ids(new_urls)), set(extract_mod_ids(old_urls)) + return old_ids, new_ids, old_info, new_info + +def cf_get_pack_data(old_json, new_json): + def get_dependency_info(json): + loader_string = json['minecraft']['modLoaders'][0]['id'] + return { + 'mc_version': json['minecraft']['version'], + 'loader': loader_string.split('-')[0], + 'loader_version': loader_string.split('-')[1] + } + + def get_mod_ids(json): + # Extracts the file and project IDs from the JSON + return {item['fileID']: item['projectID'] for item in json['files']} + + old_file_ids = get_mod_ids(old_json) + new_file_ids = get_mod_ids(new_json) + + # To reference a file cf has a project id (the mod) and a file id (the version) + # with this code we can get mod that have been changed between the two packs, so with unquie file ids + old_ids = {old_file_ids[file_id] for file_id in old_file_ids if file_id not in new_file_ids} + new_ids = {new_file_ids[file_id] for file_id in new_file_ids if file_id not in old_file_ids} + + old_info, new_info = get_dependency_info(old_json), get_dependency_info(new_json) + return old_ids, new_ids, old_info, new_info diff --git a/src/get_json.py b/src/get_json.py index dd2a10e..d7c3d36 100644 --- a/src/get_json.py +++ b/src/get_json.py @@ -4,17 +4,29 @@ import shutil import sys from zipfile import ZipFile - +import constants def get_json(path): - # Ensure the file is a modpack file - if not path.endswith('.mrpack'): - logging.error('ERROR: Input file is not a modpack') - sys.exit(1) if not os.path.exists(path): logging.error('ERROR: The file %s does not exist', path) sys.exit(1) + if path.endswith('.mrpack'): + if constants.Modpacks_Format == 'curseforge': + logging.error('ERROR: Using Modrinth and a Curseforge modpack together is not supported') + sys.exit(1) + constants.Modpacks_Format = 'modrinth' + logging.debug('Using Modrinth format modpack') + elif path.endswith('.zip'): + if constants.Modpacks_Format == 'modrinth': + logging.error('ERROR: Using Modrinth and a Curseforge modpack together is not supported') + sys.exit(1) + logging.debug('Using CurseForge format modpack') + constants.Modpacks_Format = 'curseforge' + else: + logging.error('ERROR: Input modpack is not in a supported format') + sys.exit(1) + # Create a temporary directory temp_dir = os.path.join(os.environ.get('TEMP'), 'ModpackChangelogger') os.makedirs(temp_dir, exist_ok=True) @@ -26,7 +38,7 @@ def get_json(path): logging.debug('Extracted %s to %s', path, temp_dir) # Parse the json file - json_path = os.path.join(temp_dir, 'modrinth.index.json') + json_path = os.path.join(temp_dir, 'modrinth.index.json' if constants.Modpacks_Format == 'modrinth' else 'manifest.json') with open(json_path, 'r', encoding="utf-8") as json_file: logging.debug('Parsed %s', json_path) return json.load(json_file) diff --git a/src/get_mod_names.py b/src/get_mod_names.py index e85f372..a7b0673 100644 --- a/src/get_mod_names.py +++ b/src/get_mod_names.py @@ -2,37 +2,57 @@ import json import logging import aiohttp -from constants import MODRINTH_API_URL, HEADERS +from constants import MR_API_URL, MR_HEADERS, CF_HEADERS, CF_API_URL +import constants async def get_mod_names(added_ids, removed_ids, updated_ids): - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=0.1)) as session: + api_function = { + "modrinth": request_from_mr_api, + "curseforge": request_from_cf_api + }.get(constants.Modpacks_Format, request_from_cf_api) + added_names, removed_names, updated_names = await asyncio.gather( - request_from_api(session, added_ids), - request_from_api(session, removed_ids), - request_from_api(session, updated_ids) + api_function(session, added_ids), + api_function(session, removed_ids), + api_function(session, updated_ids) ) + return added_names, removed_names, updated_names -async def request_from_api(session, ids): - # Convert the set of ids to a json array - ids_list = json.dumps(list(ids)) - URL = f"{MODRINTH_API_URL}/projects?ids={ids_list}" +async def request_from_mr_api(session, ids): names = [] + URL = f"{MR_API_URL}{json.dumps(list(ids))}" try: - async with session.get(URL, headers=HEADERS) as response: + async with session.get(URL, headers=MR_HEADERS) as response: response.raise_for_status() data = await response.json() names = [project.get('title') for project in data] - except aiohttp.ClientConnectionError as e: + except (aiohttp.ClientConnectionError, asyncio.TimeoutError, aiohttp.ClientResponseError) as e: + handle_request_errors(e, URL) + + return names + +async def request_from_cf_api(session, ids): + names = [] + URL = f"{CF_API_URL}" + + try: + async with session.post(URL, headers=CF_HEADERS, json={'modIds': list(ids)}, ssl=False) as response: + response = await response.json() + names = [project['name'] for project in response['data']] + except (aiohttp.ClientConnectionError, asyncio.TimeoutError, aiohttp.ClientResponseError) as e: + handle_request_errors(e, URL) + + return names + +def handle_request_errors(e, URL): + if isinstance(e, aiohttp.ClientConnectionError): logging.warning("Failed to connect to %s: %s", URL, e) - except asyncio.TimeoutError: - logging.warning("The request %s timed out ", URL) - except aiohttp.ClientResponseError as e: + elif isinstance(e, asyncio.TimeoutError): + logging.warning("The request %s timed out", URL) + elif isinstance(e, aiohttp.ClientResponseError): logging.warning("Server responded with an error for %s: %s", URL, e) - except aiohttp.ClientPayloadError as e: - logging.warning("Failed to read response from %s: %s", URL, e) - except aiohttp.ClientError as e: + else: logging.warning("An unexpected error occurred: %s", e) - - return names diff --git a/tests/new.zip b/tests/new.zip new file mode 100644 index 0000000..00cb374 Binary files /dev/null and b/tests/new.zip differ diff --git a/tests/old.zip b/tests/old.zip new file mode 100644 index 0000000..c674f28 Binary files /dev/null and b/tests/old.zip differ