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

Main Application Window

-

The main interface allowing selection between Local and Modrinth modes.

+

The main interface allowing selection between Local, Modrinth, and CurseForge modes.

Modrinth Search Dialog 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)