From 9639a82d9d75717c9f7f6159ee1cba60628fa8e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 07:44:38 +0000 Subject: [PATCH 1/5] Initial plan From 3c65621427a55be125f9567b467f9a129e2f763e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 07:48:14 +0000 Subject: [PATCH 2/5] Add CurseForge API integration and pack processing Co-authored-by: skippystirr <150484926+skippystirr@users.noreply.github.com> --- server_pack_builder.py | 460 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 452 insertions(+), 8 deletions(-) diff --git a/server_pack_builder.py b/server_pack_builder.py index 9141f90..d135b43 100644 --- a/server_pack_builder.py +++ b/server_pack_builder.py @@ -9,7 +9,7 @@ import threading import zipfile from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Dict, Optional, Tuple +from typing import Callable, Dict, Optional, Tuple from urllib.parse import urlparse import requests @@ -300,6 +300,137 @@ def get_modrinth_versions(slug: str) -> list: logging.error(f"Failed to fetch versions for {slug}: {e}") return [] + +# CurseForge API Functions +CURSEFORGE_API_KEY = os.environ.get("CURSEFORGE_API_KEY", "") + + +def get_curseforge_headers() -> Dict[str, str]: + """Returns headers for CurseForge API requests.""" + if not CURSEFORGE_API_KEY: + logging.warning( + "CURSEFORGE_API_KEY not set. CurseForge functionality may be limited. " + "Set the environment variable to enable full access." + ) + return { + "X-Api-Key": CURSEFORGE_API_KEY, + "Accept": "application/json", + } + + +def search_curseforge_modpacks(query: str, limit: int = 20) -> list: + """ + Searches for modpacks on CurseForge. + Returns a list of mod/modpack dictionaries. + """ + session = get_requests_session() + headers = get_curseforge_headers() + + params = { + "gameId": 432, # Minecraft + "classId": 4471, # Modpack class + "searchFilter": query, + "pageSize": limit, + "index": 0, + } + + try: + response = session.get( + "https://api.curseforge.com/v1/mods/search", params=params, headers=headers + ) + response.raise_for_status() + data = response.json() + return data.get("data", []) + except Exception as e: + logging.error(f"CurseForge search failed: {e}") + return [] + + +def get_curseforge_modpack(modpack_id: int) -> Optional[dict]: + """ + Fetches details for a specific CurseForge modpack. + Returns modpack data or None. + """ + session = get_requests_session() + headers = get_curseforge_headers() + + try: + response = session.get( + f"https://api.curseforge.com/v1/mods/{modpack_id}", headers=headers + ) + response.raise_for_status() + data = response.json() + return data.get("data") + except Exception as e: + logging.error(f"Failed to fetch CurseForge modpack {modpack_id}: {e}") + return None + + +def get_curseforge_files(modpack_id: int) -> list: + """ + Fetches available files for a CurseForge modpack. + Returns a list of file dictionaries. + """ + session = get_requests_session() + headers = get_curseforge_headers() + + try: + response = session.get( + f"https://api.curseforge.com/v1/mods/{modpack_id}/files", headers=headers + ) + response.raise_for_status() + data = response.json() + return data.get("data", []) + except Exception as e: + logging.error(f"Failed to fetch files for CurseForge modpack {modpack_id}: {e}") + return [] + + +def get_curseforge_file(modpack_id: int, file_id: int) -> Optional[dict]: + """ + Fetches a specific file from a CurseForge modpack. + Returns file data or None. + """ + session = get_requests_session() + headers = get_curseforge_headers() + + try: + response = session.get( + f"https://api.curseforge.com/v1/mods/{modpack_id}/files/{file_id}", + headers=headers, + ) + response.raise_for_status() + data = response.json() + return data.get("data") + except Exception as e: + logging.error( + f"Failed to fetch CurseForge file {file_id} for modpack {modpack_id}: {e}" + ) + return None + + +def get_curseforge_download_url(project_id: int, file_id: int) -> Optional[str]: + """ + Gets the download URL for a CurseForge mod file. + Returns URL string or None. + """ + session = get_requests_session() + headers = get_curseforge_headers() + + try: + response = session.get( + f"https://api.curseforge.com/v1/mods/{project_id}/files/{file_id}/download-url", + headers=headers, + ) + response.raise_for_status() + return response.json().get("data") + except Exception as e: + logging.debug( + f"Failed to get download URL for project {project_id}, file {file_id}: {e}" + ) + return None + + def process_modrinth_pack( modrinth_url: str, output_file: Optional[str], @@ -581,9 +712,302 @@ def check_mod_entry( traceback.print_exc() raise + +def process_curseforge_pack( + modpack_id: int, + output_file: Optional[str], + dry_run: bool, + file_id: Optional[int] = None, + progress_callback: Optional[Callable[[int, int, str], None]] = None, + download_callback: Optional[Callable[[int, int], None]] = None, +): + """ + Downloads and filters a CurseForge modpack. + """ + logging.info(f"Fetching CurseForge modpack: {modpack_id}") + + try: + # Get modpack info + modpack_data = get_curseforge_modpack(modpack_id) + if not modpack_data: + logging.error(f"Failed to fetch modpack {modpack_id}") + raise ValueError(f"Failed to fetch modpack {modpack_id}") + + modpack_name = modpack_data.get("name", f"modpack-{modpack_id}") + logging.info(f"Modpack: {modpack_name}") + + # Get files (versions) + if file_id: + file_data = get_curseforge_file(modpack_id, file_id) + if not file_data: + logging.error(f"Failed to fetch file {file_id}") + raise ValueError(f"Failed to fetch file {file_id}") + else: + # Get latest file + files = get_curseforge_files(modpack_id) + if not files: + logging.error("No files found for this modpack") + raise ValueError("No files found for this modpack") + file_data = files[0] # Latest file + + file_name = file_data.get("fileName", "modpack.zip") + download_url = file_data.get("downloadUrl") + + if not download_url: + logging.error("No download URL found for the modpack file") + raise ValueError("No download URL found for the modpack file") + + # Define output filename + if not output_file: + if not dry_run: + output_file = f"{modpack_name}-server.zip" + else: + output_file = "dry-run-output.zip" + + # Temporary work area + with tempfile.TemporaryDirectory() as temp_dir: + pack_path = os.path.join(temp_dir, "original.zip") + + logging.info(f"Downloading: {file_name}") + + # Download modpack zip + session = get_requests_session() + + def download_with_progress(url, dest_path): + with session.get(url, stream=True) as r: + r.raise_for_status() + total_length = int(r.headers.get("content-length", 0)) + downloaded = 0 + + if download_callback and total_length > 0: + download_callback(0, total_length) + + with open(dest_path, "wb") as f: + for chunk in r.iter_content(chunk_size=65536): + f.write(chunk) + downloaded += len(chunk) + if download_callback and total_length > 0: + download_callback(downloaded, total_length) + + if not dry_run: + download_with_progress(download_url, pack_path) + else: + logging.info("(Dry Run) Downloading for analysis...") + download_with_progress(download_url, pack_path) + + # Extract manifest.json + extract_dir = os.path.join(temp_dir, "extracted") + with zipfile.ZipFile(pack_path, "r") as zip_ref: + zip_ref.extractall(extract_dir) + + manifest_path = os.path.join(extract_dir, "manifest.json") + if not os.path.exists(manifest_path): + logging.error("Invalid CurseForge pack: manifest.json missing.") + raise FileNotFoundError("Invalid CurseForge pack: manifest.json missing.") + + with open(manifest_path, "r", encoding="utf-8") as f: + manifest_data = json.load(f) + + files_list = manifest_data.get("files", []) + new_files_list = [] + + # Prepare directory for new pack + new_pack_dir = os.path.join(temp_dir, "new_pack") + os.makedirs(new_pack_dir, exist_ok=True) + + # Calculate total size (we'll estimate based on count since size isn't in manifest) + total_mod_bytes = 0 + downloaded_mod_bytes = 0 + download_lock = threading.Lock() + + def mod_chunk_callback(chunk_size): + nonlocal downloaded_mod_bytes + with download_lock: + downloaded_mod_bytes += chunk_size + if download_callback and total_mod_bytes > 0: + download_callback(downloaded_mod_bytes, total_mod_bytes) + + # Copy overrides if exist + overrides_folder = manifest_data.get("overrides", "overrides") + overrides_src = os.path.join(extract_dir, overrides_folder) + if os.path.exists(overrides_src): + logging.info(f"Copying {overrides_folder} directory...") + if not dry_run: + shutil.copytree( + overrides_src, os.path.join(new_pack_dir, overrides_folder) + ) + + logging.info(f"Processing {len(files_list)} mods...") + + processed = 0 + kept = 0 + skipped = 0 + + # Reset progress for mod processing + if progress_callback: + progress_callback(0, len(files_list), "Processing mods...") + + def check_curseforge_mod_entry( + index: int, mod_entry: dict + ) -> Tuple[int, dict, bool, str, str, str]: + project_id = mod_entry.get("projectID") + file_id = mod_entry.get("fileID") + required = mod_entry.get("required", True) + + if not required: + return ( + index, + mod_entry, + True, + "Optional", + "Mod marked as not required", + f"project-{project_id}", + ) + + # Get download URL from CurseForge API + download_url = get_curseforge_download_url(project_id, file_id) + + if not download_url: + # Try to construct URL from file info + file_info = get_curseforge_file(project_id, file_id) + if file_info: + download_url = file_info.get("downloadUrl") + filename = file_info.get("fileName", f"mod-{project_id}-{file_id}.jar") + else: + return ( + index, + mod_entry, + False, + "Unknown", + f"Failed to get download info", + f"project-{project_id}", + ) + else: + # Extract filename from URL or get from API + filename = download_url.split("/")[-1] + + if not download_url: + return ( + index, + mod_entry, + False, + "Unknown", + f"No download URL available", + filename, + ) + + logging.debug(f"Checking {filename}...") + temp_handle, temp_jar_path = tempfile.mkstemp(suffix=".jar", dir=temp_dir) + os.close(temp_handle) + + try: + session = get_requests_session() + with session.get(download_url, stream=True) as r: + r.raise_for_status() + with open(temp_jar_path, "wb") as f: + for chunk in r.iter_content(chunk_size=65536): + f.write(chunk) + mod_chunk_callback(len(chunk)) + except Exception as e: + try: + os.remove(temp_jar_path) + except OSError: + pass + return ( + index, + mod_entry, + False, + "Unknown", + f"Failed to download: {e}", + filename, + ) + + is_client, mod_type, reason = check_jar_sidedness(temp_jar_path) + + try: + os.remove(temp_jar_path) + except OSError: + pass + + return index, mod_entry, is_client, mod_type, reason, filename + + max_workers = get_default_worker_count() + results: Dict[int, Tuple[dict, bool, str, str, str]] = {} + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(check_curseforge_mod_entry, index, mod_entry) + for index, mod_entry in enumerate(files_list) + ] + + for future in as_completed(futures): + ( + index, + mod_entry, + is_client, + mod_type, + reason, + filename, + ) = future.result() + results[index] = (mod_entry, is_client, mod_type, reason, filename) + + for index in range(len(files_list)): + processed += 1 + mod_entry, is_client, mod_type, reason, filename = results[index] + + if is_client: + logging.info(f"[SKIP] {filename} ({reason})") + skipped += 1 + else: + logging.info(f"[KEEP] {filename}") + new_files_list.append(mod_entry) + kept += 1 + + if progress_callback: + progress_callback( + processed, len(files_list), f"Processed {processed}/{len(files_list)}" + ) + + # Write new manifest + manifest_data["files"] = new_files_list + manifest_data["name"] = f"{manifest_data.get('name', modpack_name)} (Server)" + + if not dry_run: + with open( + os.path.join(new_pack_dir, "manifest.json"), "w", encoding="utf-8" + ) as f: + json.dump(manifest_data, f, indent=2) + + # Zip up the new pack + logging.info(f"Creating server pack: {output_file}") + with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(new_pack_dir): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, new_pack_dir) + zipf.write(file_path, arcname) + else: + logging.info("(Dry Run) Would create server pack with:") + logging.info(f" - {overrides_folder} included") + logging.info(f" - {kept} mods kept") + logging.info(f" - {skipped} mods removed") + + logging.info("-" * 40) + logging.info(f"Summary: Processed {processed} mods.") + logging.info(f"Kept: {kept}") + logging.info(f"Skipped (Client-only): {skipped}") + + except Exception as e: + logging.error(f"An unexpected error occurred: {e}") + if logging.getLogger().isEnabledFor(logging.DEBUG): + import traceback + + traceback.print_exc() + raise + + def main(): parser = argparse.ArgumentParser( - description="Minecraft Server Pack Builder: filters client-side only mods from local folders or Modrinth packs." + description="Minecraft Server Pack Builder: filters client-side only mods from local folders, Modrinth packs, or CurseForge packs." ) group = parser.add_mutually_exclusive_group(required=False) @@ -593,6 +1017,12 @@ def main(): group.add_argument( "--modrinth-url", "-m", help="Modrinth Modpack URL or Slug (Modrinth mode)" ) + group.add_argument( + "--curseforge-id", + "-c", + type=int, + help="CurseForge Modpack ID (CurseForge mode)", + ) parser.add_argument( "--destination", @@ -602,12 +1032,17 @@ def main(): parser.add_argument( "--output-file", "-o", - help="Output path for the generated .mrpack (Modrinth mode)", + help="Output path for the generated .mrpack or .zip (Modrinth/CurseForge mode)", ) parser.add_argument( "--pack-version", help="Specific version ID/Number to download (Modrinth mode). Overrides URL version.", ) + parser.add_argument( + "--file-id", + type=int, + help="Specific file ID to download (CurseForge mode). Defaults to latest.", + ) parser.add_argument( "--dry-run", action="store_true", @@ -634,8 +1069,10 @@ def main(): return # Validate arguments for CLI mode since group is no longer required=True - if not args.source and not args.modrinth_url: - parser.error("one of the arguments --source/-s --modrinth-url/-m is required") + if not args.source and not args.modrinth_url and not args.curseforge_id: + parser.error( + "one of the arguments --source/-s --modrinth-url/-m --curseforge-id/-c is required" + ) try: if args.source: @@ -644,10 +1081,17 @@ def main(): process_local_modpack(args.source, args.destination, args.dry_run) elif args.modrinth_url: process_modrinth_pack( - args.modrinth_url, - args.output_file, + args.modrinth_url, + args.output_file, + args.dry_run, + pack_version_id=args.pack_version, + ) + elif args.curseforge_id: + process_curseforge_pack( + args.curseforge_id, + args.output_file, args.dry_run, - pack_version_id=args.pack_version + file_id=args.file_id, ) except Exception as e: sys.exit(1) From a012d8f9c951510b2c275a641f5274fcc72562fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 07:50:59 +0000 Subject: [PATCH 3/5] Add CurseForge support to GUI with search and file selection Co-authored-by: skippystirr <150484926+skippystirr@users.noreply.github.com> --- gui.py | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 286 insertions(+), 2 deletions(-) diff --git a/gui.py b/gui.py index 6c2c269..30e16d5 100644 --- a/gui.py +++ b/gui.py @@ -75,6 +75,38 @@ def run(self): except Exception: self.versions_found.emit([]) + +class CFSearchWorker(QThread): + results_found = pyqtSignal(list) + error_occurred = pyqtSignal(str) + + def __init__(self, query: str): + super().__init__() + self.query = query + + def run(self): + try: + results = server_pack_builder.search_curseforge_modpacks(self.query) + self.results_found.emit(results) + except Exception as e: + self.error_occurred.emit(str(e)) + + +class CFFilesWorker(QThread): + files_found = pyqtSignal(list) + + def __init__(self, modpack_id: int): + super().__init__() + self.modpack_id = modpack_id + + def run(self): + try: + files = server_pack_builder.get_curseforge_files(self.modpack_id) + self.files_found.emit(files) + except Exception: + self.files_found.emit([]) + + class ImageLoader(QThread): image_loaded = pyqtSignal(str, QImage) # url, image @@ -222,6 +254,130 @@ def accept_selection(self): self.selected_slug = slug self.accept() + +class CurseForgeSearchDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Search CurseForge Modpacks") + self.resize(700, 500) + self.selected_id = None + self.image_threads = {} # Keep references to prevent GC + + # UI Setup + layout = QVBoxLayout(self) + + # Search Bar + search_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search for modpacks (e.g., 'All the Mods')...") + self.search_input.returnPressed.connect(self.do_search) + + self.btn_search = QPushButton("Search") + self.btn_search.clicked.connect(self.do_search) + + search_layout.addWidget(self.search_input) + search_layout.addWidget(self.btn_search) + layout.addLayout(search_layout) + + # Results List + self.list_widget = QListWidget() + self.list_widget.setIconSize(QSize(64, 64)) + self.list_widget.itemDoubleClicked.connect(self.accept_selection) + layout.addWidget(self.list_widget) + + # Buttons + btn_layout = QHBoxLayout() + self.btn_select = QPushButton("Select") + self.btn_select.clicked.connect(self.accept_selection) + self.btn_cancel = QPushButton("Cancel") + self.btn_cancel.clicked.connect(self.reject) + + btn_layout.addStretch() + btn_layout.addWidget(self.btn_select) + btn_layout.addWidget(self.btn_cancel) + layout.addLayout(btn_layout) + + self.worker = None + + def do_search(self): + query = self.search_input.text().strip() + if not query: + return + + self.list_widget.clear() + self.btn_search.setEnabled(False) + self.list_widget.addItem("Searching...") + + # Cancel previous worker if any + if self.worker and self.worker.isRunning(): + self.worker.terminate() + self.worker.wait() + + self.worker = CFSearchWorker(query) + self.worker.results_found.connect(self.populate_results) + self.worker.error_occurred.connect(self.handle_error) + self.worker.finished.connect(lambda: self.btn_search.setEnabled(True)) + self.worker.start() + + def handle_error(self, error_msg): + self.list_widget.clear() + self.list_widget.addItem(f"Error: {error_msg}") + + def populate_results(self, results): + self.list_widget.clear() + if not results: + self.list_widget.addItem("No results found.") + return + + for modpack in results: + name = modpack.get("name", "Unknown") + authors = modpack.get("authors", []) + author = authors[0].get("name", "Unknown") if authors else "Unknown" + modpack_id = modpack.get("id", 0) + summary = modpack.get("summary", "") + logo = modpack.get("logo", {}) + logo_url = logo.get("thumbnailUrl", "") if isinstance(logo, dict) else "" + + # Format text + item_text = f"{name} ({author})\n{summary}" + item = QListWidgetItem(item_text) + item.setData(Qt.ItemDataRole.UserRole, modpack_id) + item.setToolTip(f"ID: {modpack_id}\n{summary}") + + # Placeholder icon + placeholder = QPixmap(64, 64) + placeholder.fill(QColor("gray")) + item.setIcon(QIcon(placeholder)) + + self.list_widget.addItem(item) + + # Load icon if available + if logo_url: + loader = ImageLoader(logo_url) + loader.image_loaded.connect(lambda u, i, it=item: self.update_icon(it, i)) + + # Keep reference + self.image_threads[logo_url] = loader + loader.finished.connect(lambda u=logo_url: self.cleanup_thread(u)) + loader.start() + + def update_icon(self, item, image): + if not image.isNull(): + item.setIcon(QIcon(QPixmap.fromImage(image))) + + def cleanup_thread(self, url): + if url in self.image_threads: + del self.image_threads[url] + + def accept_selection(self): + item = self.list_widget.currentItem() + if item: + modpack_id = item.data(Qt.ItemDataRole.UserRole) + if modpack_id: + self.selected_id = modpack_id + self.accept() + + class WorkerThread(QThread): """Runs the long-running CLI tasks in a background thread.""" @@ -265,6 +421,15 @@ def run(self): progress_callback=self.update_progress, download_callback=self.update_download ) + elif self.mode == "curseforge": + server_pack_builder.process_curseforge_pack( + modpack_id=self.kwargs["modpack_id"], + output_file=self.kwargs["output"], + dry_run=self.kwargs["dry_run"], + file_id=self.kwargs.get("file_id"), + progress_callback=self.update_progress, + download_callback=self.update_download + ) self.finished_signal.emit(True, "Process completed successfully.") except Exception as e: self.finished_signal.emit(False, str(e)) @@ -296,10 +461,12 @@ def init_ui(self): mode_layout = QHBoxLayout() self.radio_local = QRadioButton("Local Modpack") self.radio_modrinth = QRadioButton("Modrinth Modpack") + self.radio_curseforge = QRadioButton("CurseForge Modpack") self.radio_local.setChecked(True) self.radio_local.toggled.connect(self.update_ui_state) mode_layout.addWidget(self.radio_local) mode_layout.addWidget(self.radio_modrinth) + mode_layout.addWidget(self.radio_curseforge) mode_group.setLayout(mode_layout) main_layout.addWidget(mode_group) @@ -379,6 +546,47 @@ def init_ui(self): self.input_layout.addWidget(self.modrinth_widget) self.modrinth_widget.hide() # Initial state + # CurseForge Inputs (ID/Output) + self.curseforge_widget = QWidget() + cf_layout = QVBoxLayout(self.curseforge_widget) + cf_layout.setContentsMargins(0, 0, 0, 0) + + # Modpack ID + id_layout = QHBoxLayout() + self.edit_cf_id = QLineEdit() + self.edit_cf_id.setPlaceholderText("CurseForge Modpack ID (e.g., '123456')") + self.edit_cf_id.editingFinished.connect(self.fetch_cf_files) + self.btn_search_curseforge = QPushButton("Search...") + self.btn_search_curseforge.clicked.connect(self.open_cf_search_dialog) + + id_layout.addWidget(QLabel("Modpack ID:")) + id_layout.addWidget(self.edit_cf_id) + id_layout.addWidget(self.btn_search_curseforge) + cf_layout.addLayout(id_layout) + + # File Selection + file_layout = QHBoxLayout() + self.combo_cf_file = QComboBox() + self.combo_cf_file.addItem("Latest", None) + self.combo_cf_file.setEnabled(False) + file_layout.addWidget(QLabel("File:")) + file_layout.addWidget(self.combo_cf_file) + cf_layout.addLayout(file_layout) + + # Output File + cf_out_layout = QHBoxLayout() + self.edit_cf_output = QLineEdit() + self.edit_cf_output.setPlaceholderText("Output .zip file (optional)") + btn_browse_cf_out = QPushButton("Save As...") + btn_browse_cf_out.clicked.connect(self.browse_save_cf_file) + cf_out_layout.addWidget(QLabel("Output:")) + cf_out_layout.addWidget(self.edit_cf_output) + cf_out_layout.addWidget(btn_browse_cf_out) + cf_layout.addLayout(cf_out_layout) + + self.input_layout.addWidget(self.curseforge_widget) + self.curseforge_widget.hide() # Initial state + # Options opts_layout = QHBoxLayout() self.check_dry_run = QCheckBox("Dry Run (Simulate only)") @@ -480,8 +688,12 @@ def apply_theme(self): def update_ui_state(self): is_local = self.radio_local.isChecked() + is_modrinth = self.radio_modrinth.isChecked() + is_curseforge = self.radio_curseforge.isChecked() + self.local_widget.setVisible(is_local) - self.modrinth_widget.setVisible(not is_local) + self.modrinth_widget.setVisible(is_modrinth) + self.curseforge_widget.setVisible(is_curseforge) def browse_dir(self, line_edit): path = QFileDialog.getExistingDirectory(self, "Select Directory") @@ -493,6 +705,11 @@ def browse_save_file(self): if path: self.edit_output.setText(path) + def browse_save_cf_file(self): + path, _ = QFileDialog.getSaveFileName(self, "Save Output File", "", "CurseForge Pack (*.zip)") + if path: + self.edit_cf_output.setText(path) + def append_log(self, message): self.text_logs.append(message) # Auto scroll @@ -560,7 +777,7 @@ def start_process(self): mode = "local" kwargs = {"source": source, "dest": dest, "dry_run": dry_run} - else: + elif self.radio_modrinth.isChecked(): url = self.edit_url.text().strip() output = self.edit_output.text().strip() or None if not url: @@ -572,6 +789,24 @@ def start_process(self): mode = "modrinth" kwargs = {"url": url, "output": output, "dry_run": dry_run, "version_id": version_id} + else: # CurseForge + modpack_id_str = self.edit_cf_id.text().strip() + output = self.edit_cf_output.text().strip() or None + if not modpack_id_str: + QMessageBox.warning(self, "Invalid Input", "Please enter a CurseForge Modpack ID.") + return + + try: + modpack_id = int(modpack_id_str) + except ValueError: + QMessageBox.warning(self, "Invalid Input", "Modpack ID must be a number.") + return + + file_id = self.combo_cf_file.currentData() + + mode = "curseforge" + kwargs = {"modpack_id": modpack_id, "output": output, "dry_run": dry_run, "file_id": file_id} + # Start Thread self.text_logs.clear() self.append_log("Starting process...") @@ -667,6 +902,55 @@ def populate_versions(self, versions): self.combo_version.addItem("No versions found or invalid slug", None) self.combo_version.setEnabled(True) # Allow user to still try "Latest" if they think it's right + def open_cf_search_dialog(self): + dialog = CurseForgeSearchDialog(self) + if dialog.exec() == QDialog.DialogCode.Accepted: + if dialog.selected_id: + self.edit_cf_id.setText(str(dialog.selected_id)) + self.fetch_cf_files() + + def fetch_cf_files(self): + modpack_id_str = self.edit_cf_id.text().strip() + if not modpack_id_str: + return + + try: + modpack_id = int(modpack_id_str) + except ValueError: + return + + self.combo_cf_file.clear() + self.combo_cf_file.addItem("Loading...", None) + self.combo_cf_file.setEnabled(False) + + # Use a worker for this so GUI doesn't freeze + self.cf_files_worker = CFFilesWorker(modpack_id) + self.cf_files_worker.files_found.connect(self.populate_cf_files) + self.cf_files_worker.start() + + def populate_cf_files(self, files): + self.combo_cf_file.clear() + self.combo_cf_file.addItem("Latest", None) + + if files: + for f in files: + display_name = f.get("displayName", f.get("fileName", "Unknown")) + file_id = f.get("id") + game_versions = f.get("gameVersions", []) + + # Limit display to minecraft versions only + mc_versions = [v for v in game_versions if v[0].isdigit()] + version_str = ', '.join(mc_versions[:3]) if mc_versions else "Unknown" + + display = f"{display_name} ({version_str})" + self.combo_cf_file.addItem(display, file_id) + + self.combo_cf_file.setEnabled(True) + else: + self.combo_cf_file.addItem("No files found or invalid ID", None) + self.combo_cf_file.setEnabled(True) + + def run_gui(): app = QApplication(sys.argv) window = MainWindow() From 338c6d152ec168e716d564f3735f0aef79f8f99a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 07:53:16 +0000 Subject: [PATCH 4/5] Update documentation and run linters Co-authored-by: skippystirr <150484926+skippystirr@users.noreply.github.com> --- README.md | 40 +++++- gui.py | 283 +++++++++++++++++++++++------------------ server_pack_builder.py | 104 +++++++++------ 3 files changed, 256 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index 8bdd7cc..2cfbb42 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # server pack builder -A Python CLI tool for filtering Minecraft modpacks to create server-compatible mod sets. It supports both local directory processing and direct Modrinth modpack integration. +A Python CLI tool for filtering Minecraft modpacks to create server-compatible mod sets. It supports local directory processing, Modrinth modpack integration, and CurseForge modpack integration. ## Installation @@ -10,9 +10,21 @@ Ensure Python 3.8+ is installed. Install dependencies: pip install -r requirements.txt ``` +### CurseForge API Key + +To use CurseForge features, you need to obtain an API key: + +1. Visit [CurseForge for Studios](https://console.curseforge.com/) +2. Sign in or create an account +3. Navigate to "API Keys" and generate a new key +4. Set the environment variable: + ```bash + export CURSEFORGE_API_KEY=your_api_key_here + ``` + ## Usage -The tool operates in two modes: Local and Modrinth. +The tool operates in three modes: Local, Modrinth, and CurseForge. ### Local Mode Process a local directory of mods: @@ -32,6 +44,17 @@ python server_pack_builder.py --modrinth-url https://modrinth.com/modpack/fabric python server_pack_builder.py --modrinth-url fabric-boosted --pack-version "1.2.3" ``` +### CurseForge Mode +Download and filter a CurseForge modpack by ID: + +```bash +# Download latest file +python server_pack_builder.py --curseforge-id 123456 + +# Download specific file +python server_pack_builder.py --curseforge-id 123456 --file-id 789012 +``` + ### GUI Mode Launch the graphical user interface for an easy-to-use experience: @@ -44,6 +67,7 @@ python server_pack_builder.py --gui #### Common - `--dry-run`: Simulate the process without copying or writing files. - `--verbose`, `-v`: Enable verbose logging. +- `--gui`: Launch the graphical user interface. #### Local Mode - `--source`, `-s`: Path to the source 'mods' directory. @@ -54,12 +78,18 @@ python server_pack_builder.py --gui - `--pack-version`: Specific version ID or Number to download (overrides latest). - `--output-file`, `-o`: Output path for the generated `.mrpack` (optional, defaults to `{PackName}-server.mrpack`). +#### CurseForge Mode +- `--curseforge-id`, `-c`: CurseForge Modpack ID (required for CurseForge mode). +- `--file-id`: Specific file ID to download (optional, defaults to latest). +- `--output-file`, `-o`: Output path for the generated `.zip` (optional, defaults to `{PackName}-server.zip`). + ## Features - **Client-Side Filtering**: Automatically detects and removes client-only mods by inspecting JAR metadata. - **Modrinth Integration**: Downloads, filters, and repacks `.mrpack` files. -- **Modrinth Search**: Search for modpacks and view their details/logos within the GUI. -- **Version Selection**: Choose between the latest release or specific versions of a modpack. +- **CurseForge Integration**: Downloads, filters, and repacks CurseForge modpacks (`.zip` format). +- **Modpack Search**: Search for modpacks from both Modrinth and CurseForge within the GUI. +- **Version Selection**: Choose between the latest release or specific versions/files of a modpack. - **Multi-Loader Support**: Works with Fabric and Forge mod loaders. - **Overrides Preservation**: Keeps configuration and other data from the modpack's `overrides` folder. @@ -69,7 +99,7 @@ python server_pack_builder.py --gui
Main Application Window
-The main interface allowing selection between Local and Modrinth modes.
+The main interface allowing selection between Local, Modrinth, and CurseForge modes.
diff --git a/gui.py b/gui.py
index 30e16d5..9fba16d 100644
--- a/gui.py
+++ b/gui.py
@@ -1,41 +1,26 @@
import logging
-import sys
import os
-import requests
+import sys
from typing import Optional
-from PyQt6.QtCore import QObject, QThread, pyqtSignal, Qt, QSize
-from PyQt6.QtWidgets import (
- QApplication,
- QMainWindow,
- QWidget,
- QVBoxLayout,
- QHBoxLayout,
- QLabel,
- QLineEdit,
- QPushButton,
- QRadioButton,
- QCheckBox,
- QProgressBar,
- QTextEdit,
- QFileDialog,
- QGroupBox,
- QStatusBar,
- QMessageBox,
- QDialog,
- QListWidget,
- QListWidgetItem,
- QComboBox,
-)
-from PyQt6.QtGui import QFont, QIcon, QPalette, QColor, QImage, QPixmap
+import requests
+from PyQt6.QtCore import QSize, Qt, QThread, pyqtSignal
+from PyQt6.QtGui import QColor, QFont, QIcon, QImage, QPalette, QPixmap
+from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog,
+ QFileDialog, QGroupBox, QHBoxLayout, QLabel,
+ QLineEdit, QListWidget, QListWidgetItem,
+ QMainWindow, QMessageBox, QProgressBar,
+ QPushButton, QRadioButton, QStatusBar, QTextEdit,
+ QVBoxLayout, QWidget)
import server_pack_builder
# --- Logging Integration ---
+
class GUILogHandler(logging.Handler):
"""Captures logs and emits them via a signal."""
-
+
def __init__(self, signal):
super().__init__()
self.signal = signal
@@ -44,8 +29,10 @@ def emit(self, record):
msg = self.format(record)
self.signal.emit(msg)
+
# --- Worker Thread ---
+
class SearchWorker(QThread):
results_found = pyqtSignal(list)
error_occurred = pyqtSignal(str)
@@ -61,9 +48,10 @@ def run(self):
except Exception as e:
self.error_occurred.emit(str(e))
+
class VersionsWorker(QThread):
versions_found = pyqtSignal(list)
-
+
def __init__(self, slug: str):
super().__init__()
self.slug = slug
@@ -94,7 +82,7 @@ def run(self):
class CFFilesWorker(QThread):
files_found = pyqtSignal(list)
-
+
def __init__(self, modpack_id: int):
super().__init__()
self.modpack_id = modpack_id
@@ -125,6 +113,7 @@ def run(self):
except Exception:
pass # Fail silently for icons
+
class ModrinthSearchDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
@@ -139,12 +128,14 @@ def __init__(self, parent=None):
# Search Bar
search_layout = QHBoxLayout()
self.search_input = QLineEdit()
- self.search_input.setPlaceholderText("Search for modpacks (e.g., 'Better MC')...")
+ self.search_input.setPlaceholderText(
+ "Search for modpacks (e.g., 'Better MC')..."
+ )
self.search_input.returnPressed.connect(self.do_search)
-
+
self.btn_search = QPushButton("Search")
self.btn_search.clicked.connect(self.do_search)
-
+
search_layout.addWidget(self.search_input)
search_layout.addWidget(self.btn_search)
layout.addLayout(search_layout)
@@ -161,7 +152,7 @@ def __init__(self, parent=None):
self.btn_select.clicked.connect(self.accept_selection)
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.clicked.connect(self.reject)
-
+
btn_layout.addStretch()
btn_layout.addWidget(self.btn_select)
btn_layout.addWidget(self.btn_cancel)
@@ -177,7 +168,7 @@ def do_search(self):
self.list_widget.clear()
self.btn_search.setEnabled(False)
self.list_widget.addItem("Searching...")
-
+
# Cancel previous worker if any
if self.worker and self.worker.isRunning():
self.worker.terminate()
@@ -211,28 +202,30 @@ def populate_results(self, hits):
item = QListWidgetItem(item_text)
item.setData(Qt.ItemDataRole.UserRole, slug)
item.setToolTip(f"Slug: {slug}\n{desc}")
-
+
# Placeholder icon
placeholder = QPixmap(64, 64)
placeholder.fill(QColor("gray"))
item.setIcon(QIcon(placeholder))
self.list_widget.addItem(item)
-
+
# Load icon if available
if icon_url:
loader = ImageLoader(icon_url)
# Use a closure or default arg to capture 'item' correctly?
- # Actually, ImageLoader emits url, we can map url back to item,
+ # Actually, ImageLoader emits url, we can map url back to item,
# OR we can pass item to ImageLoader (but QThread shouldn't touch UI directly).
# Safer: connect signal to a slot that updates the item.
# Since we loop, we need to know WHICH item to update.
# I'll just store the item in a map keyed by URL? No, multiple packs might share icon? Unlikely.
# Better: Make a custom slot or lambda.
-
+
# We need to be careful with lambdas in loops.
- loader.image_loaded.connect(lambda u, i, it=item: self.update_icon(it, i))
-
+ loader.image_loaded.connect(
+ lambda u, i, it=item: self.update_icon(it, i)
+ )
+
# Keep reference
self.image_threads[icon_url] = loader
loader.finished.connect(lambda u=icon_url: self.cleanup_thread(u))
@@ -269,12 +262,14 @@ def __init__(self, parent=None):
# Search Bar
search_layout = QHBoxLayout()
self.search_input = QLineEdit()
- self.search_input.setPlaceholderText("Search for modpacks (e.g., 'All the Mods')...")
+ self.search_input.setPlaceholderText(
+ "Search for modpacks (e.g., 'All the Mods')..."
+ )
self.search_input.returnPressed.connect(self.do_search)
-
+
self.btn_search = QPushButton("Search")
self.btn_search.clicked.connect(self.do_search)
-
+
search_layout.addWidget(self.search_input)
search_layout.addWidget(self.btn_search)
layout.addLayout(search_layout)
@@ -291,7 +286,7 @@ def __init__(self, parent=None):
self.btn_select.clicked.connect(self.accept_selection)
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.clicked.connect(self.reject)
-
+
btn_layout.addStretch()
btn_layout.addWidget(self.btn_select)
btn_layout.addWidget(self.btn_cancel)
@@ -307,7 +302,7 @@ def do_search(self):
self.list_widget.clear()
self.btn_search.setEnabled(False)
self.list_widget.addItem("Searching...")
-
+
# Cancel previous worker if any
if self.worker and self.worker.isRunning():
self.worker.terminate()
@@ -343,19 +338,21 @@ def populate_results(self, results):
item = QListWidgetItem(item_text)
item.setData(Qt.ItemDataRole.UserRole, modpack_id)
item.setToolTip(f"ID: {modpack_id}\n{summary}")
-
+
# Placeholder icon
placeholder = QPixmap(64, 64)
placeholder.fill(QColor("gray"))
item.setIcon(QIcon(placeholder))
self.list_widget.addItem(item)
-
+
# Load icon if available
if logo_url:
loader = ImageLoader(logo_url)
- loader.image_loaded.connect(lambda u, i, it=item: self.update_icon(it, i))
-
+ loader.image_loaded.connect(
+ lambda u, i, it=item: self.update_icon(it, i)
+ )
+
# Keep reference
self.image_threads[logo_url] = loader
loader.finished.connect(lambda u=logo_url: self.cleanup_thread(u))
@@ -380,11 +377,11 @@ def accept_selection(self):
class WorkerThread(QThread):
"""Runs the long-running CLI tasks in a background thread."""
-
+
log_signal = pyqtSignal(str)
progress_signal = pyqtSignal(int, int, str) # current, total, message
- download_signal = pyqtSignal(int, int) # current_bytes, total_bytes
- finished_signal = pyqtSignal(bool, str) # success, message
+ download_signal = pyqtSignal(int, int) # current_bytes, total_bytes
+ finished_signal = pyqtSignal(bool, str) # success, message
def __init__(self, mode: str, kwargs: dict):
super().__init__()
@@ -410,7 +407,7 @@ def run(self):
source=self.kwargs["source"],
dest=self.kwargs["dest"],
dry_run=self.kwargs["dry_run"],
- progress_callback=self.update_progress
+ progress_callback=self.update_progress,
)
elif self.mode == "modrinth":
server_pack_builder.process_modrinth_pack(
@@ -419,7 +416,7 @@ def run(self):
dry_run=self.kwargs["dry_run"],
pack_version_id=self.kwargs.get("version_id"),
progress_callback=self.update_progress,
- download_callback=self.update_download
+ download_callback=self.update_download,
)
elif self.mode == "curseforge":
server_pack_builder.process_curseforge_pack(
@@ -428,7 +425,7 @@ def run(self):
dry_run=self.kwargs["dry_run"],
file_id=self.kwargs.get("file_id"),
progress_callback=self.update_progress,
- download_callback=self.update_download
+ download_callback=self.update_download,
)
self.finished_signal.emit(True, "Process completed successfully.")
except Exception as e:
@@ -437,15 +434,17 @@ def run(self):
# Reset logging to avoid side effects if run again (though handler instance changes)
pass
+
# --- Main Window ---
+
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Minecraft Server Pack Builder")
self.setMinimumSize(600, 500)
self.worker: Optional[WorkerThread] = None
-
+
# Setup UI
self.init_ui()
self.apply_theme()
@@ -480,7 +479,7 @@ def init_ui(self):
self.local_widget = QWidget()
local_layout = QVBoxLayout(self.local_widget)
local_layout.setContentsMargins(0, 0, 0, 0)
-
+
# Source
src_layout = QHBoxLayout()
self.edit_source = QLineEdit()
@@ -502,7 +501,7 @@ def init_ui(self):
dest_layout.addWidget(self.edit_dest)
dest_layout.addWidget(btn_browse_dest)
local_layout.addLayout(dest_layout)
-
+
self.input_layout.addWidget(self.local_widget)
# Modrinth Inputs (URL/Output)
@@ -517,7 +516,7 @@ def init_ui(self):
self.edit_url.editingFinished.connect(self.fetch_versions)
self.btn_search_modrinth = QPushButton("Search...")
self.btn_search_modrinth.clicked.connect(self.open_search_dialog)
-
+
url_layout.addWidget(QLabel("URL/Slug:"))
url_layout.addWidget(self.edit_url)
url_layout.addWidget(self.btn_search_modrinth)
@@ -544,7 +543,7 @@ def init_ui(self):
mod_layout.addLayout(out_layout)
self.input_layout.addWidget(self.modrinth_widget)
- self.modrinth_widget.hide() # Initial state
+ self.modrinth_widget.hide() # Initial state
# CurseForge Inputs (ID/Output)
self.curseforge_widget = QWidget()
@@ -558,7 +557,7 @@ def init_ui(self):
self.edit_cf_id.editingFinished.connect(self.fetch_cf_files)
self.btn_search_curseforge = QPushButton("Search...")
self.btn_search_curseforge.clicked.connect(self.open_cf_search_dialog)
-
+
id_layout.addWidget(QLabel("Modpack ID:"))
id_layout.addWidget(self.edit_cf_id)
id_layout.addWidget(self.btn_search_curseforge)
@@ -585,7 +584,7 @@ def init_ui(self):
cf_layout.addLayout(cf_out_layout)
self.input_layout.addWidget(self.curseforge_widget)
- self.curseforge_widget.hide() # Initial state
+ self.curseforge_widget.hide() # Initial state
# Options
opts_layout = QHBoxLayout()
@@ -620,7 +619,7 @@ def init_ui(self):
self.lbl_progress = QLabel("")
self.progress_bar = QProgressBar()
self.progress_bar.setTextVisible(True)
- self.progress_bar.setFormat("%p%") # Show percentage
+ self.progress_bar.setFormat("%p%") # Show percentage
progress_layout.addWidget(self.lbl_progress)
progress_layout.addWidget(self.progress_bar)
main_layout.addLayout(progress_layout)
@@ -633,15 +632,15 @@ def init_ui(self):
def apply_theme(self):
# Fusion Theme with Dark Palette
app = QApplication.instance()
-
+
# In PyQt6, instance() returns QCoreApplication, which doesn't have setStyle/setPalette type hints
# matching what we expect from QApplication. However, since we created a QApplication in run_gui(),
# the instance IS a QApplication at runtime.
-
+
# If we want to be safe and satisfy linters (and logic), we check/cast.
if isinstance(app, QApplication):
app.setStyle("Fusion")
-
+
palette = QPalette()
# ... (colors setup) ...
palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
@@ -658,7 +657,7 @@ def apply_theme(self):
palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
app.setPalette(palette)
-
+
# Additional Stylesheet for polish
app.setStyleSheet("""
QGroupBox {
@@ -690,7 +689,7 @@ def update_ui_state(self):
is_local = self.radio_local.isChecked()
is_modrinth = self.radio_modrinth.isChecked()
is_curseforge = self.radio_curseforge.isChecked()
-
+
self.local_widget.setVisible(is_local)
self.modrinth_widget.setVisible(is_modrinth)
self.curseforge_widget.setVisible(is_curseforge)
@@ -701,12 +700,16 @@ def browse_dir(self, line_edit):
line_edit.setText(path)
def browse_save_file(self):
- path, _ = QFileDialog.getSaveFileName(self, "Save Output File", "", "Modrinth Pack (*.mrpack)")
+ path, _ = QFileDialog.getSaveFileName(
+ self, "Save Output File", "", "Modrinth Pack (*.mrpack)"
+ )
if path:
self.edit_output.setText(path)
def browse_save_cf_file(self):
- path, _ = QFileDialog.getSaveFileName(self, "Save Output File", "", "CurseForge Pack (*.zip)")
+ path, _ = QFileDialog.getSaveFileName(
+ self, "Save Output File", "", "CurseForge Pack (*.zip)"
+ )
if path:
self.edit_cf_output.setText(path)
@@ -721,10 +724,12 @@ def update_progress_bar(self, current, total, message):
if total > 0:
self.progress_bar.setRange(0, total)
self.progress_bar.setValue(current)
- self.progress_bar.setFormat(f"{int(current/total*100)}% ({current}/{total})")
+ self.progress_bar.setFormat(
+ f"{int(current/total*100)}% ({current}/{total})"
+ )
else:
self.progress_bar.setRange(0, 0)
-
+
if message:
self.lbl_progress.setText(message)
@@ -735,22 +740,26 @@ def update_download_progress(self, current_bytes, total_bytes):
# Convert to MB for display
curr_mb = current_bytes / (1024 * 1024)
total_mb = total_bytes / (1024 * 1024)
- self.progress_bar.setFormat(f"{int(current_bytes/total_bytes*100)}% ({curr_mb:.2f}/{total_mb:.2f} MB)")
- self.lbl_progress.setText(f"Downloading... {curr_mb:.2f} MB / {total_mb:.2f} MB")
+ self.progress_bar.setFormat(
+ f"{int(current_bytes/total_bytes*100)}% ({curr_mb:.2f}/{total_mb:.2f} MB)"
+ )
+ self.lbl_progress.setText(
+ f"Downloading... {curr_mb:.2f} MB / {total_mb:.2f} MB"
+ )
else:
self.progress_bar.setRange(0, 0)
self.progress_bar.setFormat("%p%")
self.lbl_progress.setText("Downloading... (Unknown size)")
def process_finished(self, success, message):
- self.progress_bar.setRange(0, 100) # Reset from indeterminate
+ self.progress_bar.setRange(0, 100) # Reset from indeterminate
self.progress_bar.setValue(100 if success else 0)
self.progress_bar.setFormat("%p%")
self.lbl_progress.setText("Process Completed" if success else "Process Failed")
self.btn_run.setEnabled(True)
self.btn_cancel.setEnabled(False)
self.input_group.setEnabled(True)
-
+
if success:
self.status_bar.showMessage("Done!")
QMessageBox.information(self, "Success", message)
@@ -763,55 +772,75 @@ def process_finished(self, success, message):
def start_process(self):
# Validate inputs
dry_run = self.check_dry_run.isChecked()
-
+
if self.radio_local.isChecked():
source = self.edit_source.text().strip()
dest = self.edit_dest.text().strip()
if not source or not os.path.isdir(source):
- QMessageBox.warning(self, "Invalid Input", "Please select a valid source directory.")
+ QMessageBox.warning(
+ self, "Invalid Input", "Please select a valid source directory."
+ )
return
if not dest:
- QMessageBox.warning(self, "Invalid Input", "Please select a destination directory.")
+ QMessageBox.warning(
+ self, "Invalid Input", "Please select a destination directory."
+ )
return
-
+
mode = "local"
kwargs = {"source": source, "dest": dest, "dry_run": dry_run}
-
+
elif self.radio_modrinth.isChecked():
url = self.edit_url.text().strip()
output = self.edit_output.text().strip() or None
if not url:
- QMessageBox.warning(self, "Invalid Input", "Please enter a Modrinth URL or Slug.")
+ QMessageBox.warning(
+ self, "Invalid Input", "Please enter a Modrinth URL or Slug."
+ )
return
-
+
version_id = self.combo_version.currentData()
-
+
mode = "modrinth"
- kwargs = {"url": url, "output": output, "dry_run": dry_run, "version_id": version_id}
+ kwargs = {
+ "url": url,
+ "output": output,
+ "dry_run": dry_run,
+ "version_id": version_id,
+ }
else: # CurseForge
modpack_id_str = self.edit_cf_id.text().strip()
output = self.edit_cf_output.text().strip() or None
if not modpack_id_str:
- QMessageBox.warning(self, "Invalid Input", "Please enter a CurseForge Modpack ID.")
+ QMessageBox.warning(
+ self, "Invalid Input", "Please enter a CurseForge Modpack ID."
+ )
return
-
+
try:
modpack_id = int(modpack_id_str)
except ValueError:
- QMessageBox.warning(self, "Invalid Input", "Modpack ID must be a number.")
+ QMessageBox.warning(
+ self, "Invalid Input", "Modpack ID must be a number."
+ )
return
-
+
file_id = self.combo_cf_file.currentData()
-
+
mode = "curseforge"
- kwargs = {"modpack_id": modpack_id, "output": output, "dry_run": dry_run, "file_id": file_id}
+ kwargs = {
+ "modpack_id": modpack_id,
+ "output": output,
+ "dry_run": dry_run,
+ "file_id": file_id,
+ }
# Start Thread
self.text_logs.clear()
self.append_log("Starting process...")
self.status_bar.showMessage("Running...")
- self.progress_bar.setRange(0, 0) # Indeterminate
+ self.progress_bar.setRange(0, 0) # Indeterminate
self.btn_run.setEnabled(False)
self.btn_cancel.setEnabled(True)
self.input_group.setEnabled(False)
@@ -825,28 +854,31 @@ def start_process(self):
def cancel_process(self):
if self.worker and self.worker.isRunning():
- # Terminating threads is harsh, but Python threads are hard to kill gracefully
- # without logic changes in the loop.
- # For now, we will warn user or just try terminate if supported,
+ # Terminating threads is harsh, but Python threads are hard to kill gracefully
+ # without logic changes in the loop.
+ # For now, we will warn user or just try terminate if supported,
# but ideally we'd set a flag in the worker logic.
# Since the CLI functions don't check a "stop" flag, we might have to just let it finish or use terminate().
# process_local_modpack uses ThreadPoolExecutor, so main thread is just waiting.
-
- reply = QMessageBox.question(self, "Cancel",
- "Stopping the process abruptly might leave partial files. Are you sure?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
-
+
+ reply = QMessageBox.question(
+ self,
+ "Cancel",
+ "Stopping the process abruptly might leave partial files. Are you sure?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+
if reply == QMessageBox.StandardButton.Yes:
- self.worker.terminate() # Unsafe but effective for immediate stop
+ self.worker.terminate() # Unsafe but effective for immediate stop
self.worker.wait()
self.append_log("\n[CANCELLED] Process cancelled by user.")
-
+
# Reset UI on cancel
self.progress_bar.setValue(0)
self.progress_bar.setRange(0, 100)
self.progress_bar.setFormat("%p%")
self.lbl_progress.setText("")
-
+
self.process_finished(False, "Process cancelled.")
def open_search_dialog(self):
@@ -863,21 +895,21 @@ def fetch_versions(self):
# Simple check if it looks like a slug/url
if "modrinth.com" in slug_or_url:
- # Extract slug from URL if possible, otherwise let backend handle it?
- # get_modrinth_project_version is in backend.
- # We can't easily call it here without importing or duplicating.
- # We can just try to search versions for the slug/ID extracted.
- # For now, let's just pass the text. If it's a URL, the backend might fail to find versions
- # unless we extract the slug first.
- pass
+ # Extract slug from URL if possible, otherwise let backend handle it?
+ # get_modrinth_project_version is in backend.
+ # We can't easily call it here without importing or duplicating.
+ # We can just try to search versions for the slug/ID extracted.
+ # For now, let's just pass the text. If it's a URL, the backend might fail to find versions
+ # unless we extract the slug first.
+ pass
# Use backend helper to extract slug if needed?
slug, _ = server_pack_builder.get_modrinth_project_version(slug_or_url)
-
+
self.combo_version.clear()
self.combo_version.addItem("Loading...", None)
self.combo_version.setEnabled(False)
-
+
# We need a worker for this so GUI doesn't freeze
self.version_worker = VersionsWorker(slug)
self.version_worker.versions_found.connect(self.populate_versions)
@@ -886,21 +918,23 @@ def fetch_versions(self):
def populate_versions(self, versions):
self.combo_version.clear()
self.combo_version.addItem("Latest", None)
-
+
if versions:
for v in versions:
name = v.get("name", "Unknown")
vid = v.get("id")
game_versions = v.get("game_versions", [])
loaders = v.get("loaders", [])
-
+
display = f"{name} ({', '.join(game_versions)}) - {', '.join(loaders)}"
self.combo_version.addItem(display, vid)
-
+
self.combo_version.setEnabled(True)
else:
- self.combo_version.addItem("No versions found or invalid slug", None)
- self.combo_version.setEnabled(True) # Allow user to still try "Latest" if they think it's right
+ self.combo_version.addItem("No versions found or invalid slug", None)
+ self.combo_version.setEnabled(
+ True
+ ) # Allow user to still try "Latest" if they think it's right
def open_cf_search_dialog(self):
dialog = CurseForgeSearchDialog(self)
@@ -918,11 +952,11 @@ def fetch_cf_files(self):
modpack_id = int(modpack_id_str)
except ValueError:
return
-
+
self.combo_cf_file.clear()
self.combo_cf_file.addItem("Loading...", None)
self.combo_cf_file.setEnabled(False)
-
+
# Use a worker for this so GUI doesn't freeze
self.cf_files_worker = CFFilesWorker(modpack_id)
self.cf_files_worker.files_found.connect(self.populate_cf_files)
@@ -931,20 +965,20 @@ def fetch_cf_files(self):
def populate_cf_files(self, files):
self.combo_cf_file.clear()
self.combo_cf_file.addItem("Latest", None)
-
+
if files:
for f in files:
display_name = f.get("displayName", f.get("fileName", "Unknown"))
file_id = f.get("id")
game_versions = f.get("gameVersions", [])
-
+
# Limit display to minecraft versions only
mc_versions = [v for v in game_versions if v[0].isdigit()]
- version_str = ', '.join(mc_versions[:3]) if mc_versions else "Unknown"
-
+ version_str = ", ".join(mc_versions[:3]) if mc_versions else "Unknown"
+
display = f"{display_name} ({version_str})"
self.combo_cf_file.addItem(display, file_id)
-
+
self.combo_cf_file.setEnabled(True)
else:
self.combo_cf_file.addItem("No files found or invalid ID", None)
@@ -957,5 +991,6 @@ def run_gui():
window.show()
sys.exit(app.exec())
+
if __name__ == "__main__":
run_gui()
diff --git a/server_pack_builder.py b/server_pack_builder.py
index d135b43..f982139 100644
--- a/server_pack_builder.py
+++ b/server_pack_builder.py
@@ -160,15 +160,11 @@ def check_jar_sidedness(file_path: str) -> Tuple[bool, str, str]:
return is_client, mod_type, reason
-from typing import Callable, Dict, Optional, Tuple
-
-# ... existing imports ...
-
def process_local_modpack(
- source: str,
- dest: str,
- dry_run: bool,
- progress_callback: Optional[Callable[[int, int], None]] = None
+ source: str,
+ dest: str,
+ dry_run: bool,
+ progress_callback: Optional[Callable[[int, int], None]] = None,
):
"""
Iterates through mods in source, determines sidedness, and copies to dest.
@@ -187,7 +183,7 @@ def process_local_modpack(
skipped = 0
logging.info(f"Scanning {total_mods} JAR files in '{source}'...")
-
+
# Initial callback
if progress_callback:
progress_callback(0, total_mods)
@@ -208,7 +204,7 @@ def analyze_jar(index: int, filename: str) -> Tuple[int, str, str, bool, str, st
for future in as_completed(futures):
index, filename, file_path, is_client, mod_type, reason = future.result()
results[index] = (filename, file_path, is_client, mod_type, reason)
-
+
# Since analyze_jar is done, we could update progress here if we wanted fine-grained
# async progress, but the original code loops linearly for logging/copying.
# We'll update in the main loop below for consistent order.
@@ -228,7 +224,7 @@ def analyze_jar(index: int, filename: str) -> Tuple[int, str, str, bool, str, st
except Exception as e:
logging.error(f"Failed to copy {filename}: {e}")
copied += 1
-
+
if progress_callback:
progress_callback(processed, total_mods)
@@ -269,14 +265,10 @@ def search_modrinth_modpacks(query: str, limit: int = 20) -> list:
Returns a list of project dictionaries (hits).
"""
session = get_requests_session()
-
+
# Facet for modpacks only: [["project_type:modpack"]]
- params = {
- "query": query,
- "facets": '[["project_type:modpack"]]',
- "limit": limit
- }
-
+ params = {"query": query, "facets": '[["project_type:modpack"]]', "limit": limit}
+
try:
response = session.get("https://api.modrinth.com/v2/search", params=params)
response.raise_for_status()
@@ -286,6 +278,7 @@ def search_modrinth_modpacks(query: str, limit: int = 20) -> list:
logging.error(f"Modrinth search failed: {e}")
return []
+
def get_modrinth_versions(slug: str) -> list:
"""
Fetches available versions for a modpack project.
@@ -432,18 +425,22 @@ def get_curseforge_download_url(project_id: int, file_id: int) -> Optional[str]:
def process_modrinth_pack(
- modrinth_url: str,
- output_file: Optional[str],
+ modrinth_url: str,
+ output_file: Optional[str],
dry_run: bool,
pack_version_id: Optional[str] = None,
- progress_callback: Optional[Callable[[int, int, str], None]] = None, # (current, total, status_msg)
- download_callback: Optional[Callable[[int, int], None]] = None # (current_bytes, total_bytes)
+ progress_callback: Optional[
+ Callable[[int, int, str], None]
+ ] = None, # (current, total, status_msg)
+ download_callback: Optional[
+ Callable[[int, int], None]
+ ] = None, # (current_bytes, total_bytes)
):
"""
Downloads and filters a Modrinth modpack.
"""
slug, version_id_from_url = get_modrinth_project_version(modrinth_url)
-
+
# Use the explicitly provided version ID if available, otherwise fall back to URL parsed one
version_id = pack_version_id if pack_version_id else version_id_from_url
@@ -458,7 +455,9 @@ def process_modrinth_pack(
logging.error(
f"Failed to fetch project info: {project_resp.status_code} {project_resp.text}"
)
- raise RuntimeError(f"Failed to fetch project info: {project_resp.status_code}")
+ raise RuntimeError(
+ f"Failed to fetch project info: {project_resp.status_code}"
+ )
project_data = project_resp.json()
if project_data.get("project_type") != "modpack":
@@ -471,16 +470,22 @@ def process_modrinth_pack(
# Get Version Info
if version_id:
- version_resp = session.get(f"https://api.modrinth.com/v2/version/{version_id}")
+ version_resp = session.get(
+ f"https://api.modrinth.com/v2/version/{version_id}"
+ )
else:
# Get versions list and pick latest
- version_resp = session.get(f"https://api.modrinth.com/v2/project/{slug}/version")
+ version_resp = session.get(
+ f"https://api.modrinth.com/v2/project/{slug}/version"
+ )
if version_resp.status_code != 200:
logging.error(
f"Failed to fetch version info: {version_resp.status_code} {version_resp.text}"
)
- raise RuntimeError(f"Failed to fetch version info: {version_resp.status_code}")
+ raise RuntimeError(
+ f"Failed to fetch version info: {version_resp.status_code}"
+ )
version_data = version_resp.json()
if isinstance(version_data, list):
@@ -514,17 +519,17 @@ def process_modrinth_pack(
pack_path = os.path.join(temp_dir, "original.mrpack")
logging.info(f"Downloading: {mrpack_url}")
-
+
# Helper to download with progress
def download_with_progress(url, dest_path):
with session.get(url, stream=True) as r:
r.raise_for_status()
- total_length = int(r.headers.get('content-length', 0))
+ total_length = int(r.headers.get("content-length", 0))
downloaded = 0
-
+
if download_callback and total_length > 0:
download_callback(0, total_length)
-
+
with open(dest_path, "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
f.write(chunk)
@@ -590,7 +595,7 @@ def mod_chunk_callback(chunk_size):
processed = 0
kept = 0
skipped = 0
-
+
# Reset progress for mod processing
if progress_callback:
progress_callback(0, len(files_list), "Processing mods...")
@@ -669,9 +674,13 @@ def check_mod_entry(
logging.info(f"[KEEP] {filename}")
new_files_list.append(mod_entry)
kept += 1
-
+
if progress_callback:
- progress_callback(processed, len(files_list), f"Processed {processed}/{len(files_list)}")
+ progress_callback(
+ processed,
+ len(files_list),
+ f"Processed {processed}/{len(files_list)}",
+ )
# Write new index
index_data["files"] = new_files_list
@@ -803,7 +812,9 @@ def download_with_progress(url, dest_path):
manifest_path = os.path.join(extract_dir, "manifest.json")
if not os.path.exists(manifest_path):
logging.error("Invalid CurseForge pack: manifest.json missing.")
- raise FileNotFoundError("Invalid CurseForge pack: manifest.json missing.")
+ raise FileNotFoundError(
+ "Invalid CurseForge pack: manifest.json missing."
+ )
with open(manifest_path, "r", encoding="utf-8") as f:
manifest_data = json.load(f)
@@ -872,14 +883,16 @@ def check_curseforge_mod_entry(
file_info = get_curseforge_file(project_id, file_id)
if file_info:
download_url = file_info.get("downloadUrl")
- filename = file_info.get("fileName", f"mod-{project_id}-{file_id}.jar")
+ filename = file_info.get(
+ "fileName", f"mod-{project_id}-{file_id}.jar"
+ )
else:
return (
index,
mod_entry,
False,
"Unknown",
- f"Failed to get download info",
+ "Failed to get download info",
f"project-{project_id}",
)
else:
@@ -892,12 +905,14 @@ def check_curseforge_mod_entry(
mod_entry,
False,
"Unknown",
- f"No download URL available",
+ "No download URL available",
filename,
)
logging.debug(f"Checking {filename}...")
- temp_handle, temp_jar_path = tempfile.mkstemp(suffix=".jar", dir=temp_dir)
+ temp_handle, temp_jar_path = tempfile.mkstemp(
+ suffix=".jar", dir=temp_dir
+ )
os.close(temp_handle)
try:
@@ -964,12 +979,16 @@ def check_curseforge_mod_entry(
if progress_callback:
progress_callback(
- processed, len(files_list), f"Processed {processed}/{len(files_list)}"
+ processed,
+ len(files_list),
+ f"Processed {processed}/{len(files_list)}",
)
# Write new manifest
manifest_data["files"] = new_files_list
- manifest_data["name"] = f"{manifest_data.get('name', modpack_name)} (Server)"
+ manifest_data["name"] = (
+ f"{manifest_data.get('name', modpack_name)} (Server)"
+ )
if not dry_run:
with open(
@@ -1061,6 +1080,7 @@ def main():
if args.gui:
try:
from gui import run_gui
+
run_gui()
except ImportError as e:
logging.error(f"Failed to load GUI: {e}")
@@ -1093,7 +1113,7 @@ def main():
args.dry_run,
file_id=args.file_id,
)
- except Exception as e:
+ except Exception:
sys.exit(1)
From 9744d31177cb7268c9d623a78c23cd6de29a8638 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 28 Jan 2026 07:57:36 +0000
Subject: [PATCH 5/5] Address code review feedback - fix API key warning,
optional mods, and version filtering
Co-authored-by: skippystirr <150484926+skippystirr@users.noreply.github.com>
---
gui.py | 4 ++--
server_pack_builder.py | 18 ++++++------------
2 files changed, 8 insertions(+), 14 deletions(-)
diff --git a/gui.py b/gui.py
index 9fba16d..be6af0a 100644
--- a/gui.py
+++ b/gui.py
@@ -972,8 +972,8 @@ def populate_cf_files(self, files):
file_id = f.get("id")
game_versions = f.get("gameVersions", [])
- # Limit display to minecraft versions only
- mc_versions = [v for v in game_versions if v[0].isdigit()]
+ # Limit display to minecraft versions only (those starting with digits)
+ mc_versions = [v for v in game_versions if v and v[0].isdigit()]
version_str = ", ".join(mc_versions[:3]) if mc_versions else "Unknown"
display = f"{display_name} ({version_str})"
diff --git a/server_pack_builder.py b/server_pack_builder.py
index f982139..b2921e9 100644
--- a/server_pack_builder.py
+++ b/server_pack_builder.py
@@ -296,15 +296,18 @@ def get_modrinth_versions(slug: str) -> list:
# CurseForge API Functions
CURSEFORGE_API_KEY = os.environ.get("CURSEFORGE_API_KEY", "")
+_curseforge_key_warning_shown = False
def get_curseforge_headers() -> Dict[str, str]:
"""Returns headers for CurseForge API requests."""
- if not CURSEFORGE_API_KEY:
+ global _curseforge_key_warning_shown
+ if not CURSEFORGE_API_KEY and not _curseforge_key_warning_shown:
logging.warning(
"CURSEFORGE_API_KEY not set. CurseForge functionality may be limited. "
"Set the environment variable to enable full access."
)
+ _curseforge_key_warning_shown = True
return {
"X-Api-Key": CURSEFORGE_API_KEY,
"Accept": "application/json",
@@ -863,17 +866,8 @@ def check_curseforge_mod_entry(
) -> Tuple[int, dict, bool, str, str, str]:
project_id = mod_entry.get("projectID")
file_id = mod_entry.get("fileID")
- required = mod_entry.get("required", True)
-
- if not required:
- return (
- index,
- mod_entry,
- True,
- "Optional",
- "Mod marked as not required",
- f"project-{project_id}",
- )
+ # Note: We still check optional mods for sidedness
+ # as they could be server-side optional mods
# Get download URL from CurseForge API
download_url = get_curseforge_download_url(project_id, file_id)