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 6c2c269..be6af0a 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
@@ -75,6 +63,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
@@ -93,6 +113,7 @@ def run(self):
except Exception:
pass # Fail silently for icons
+
class ModrinthSearchDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
@@ -107,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)
@@ -129,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)
@@ -145,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()
@@ -179,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))
@@ -222,13 +247,141 @@ 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."""
-
+
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__()
@@ -254,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(
@@ -263,7 +416,16 @@ 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(
+ 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:
@@ -272,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()
@@ -296,10 +460,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)
@@ -313,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()
@@ -335,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)
@@ -350,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)
@@ -377,7 +543,48 @@ 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()
+ 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()
@@ -412,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)
@@ -425,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))
@@ -450,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 {
@@ -480,8 +687,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")
@@ -489,10 +700,19 @@ 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)"
+ )
+ if path:
+ self.edit_cf_output.setText(path)
+
def append_log(self, message):
self.text_logs.append(message)
# Auto scroll
@@ -504,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)
@@ -518,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)
@@ -546,37 +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}
-
- else:
+
+ 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."
+ )
+ 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...")
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)
@@ -590,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):
@@ -628,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)
@@ -651,21 +918,72 @@ 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)
+ 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 (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})"
+ 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)
@@ -673,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 9141f90..b2921e9 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
@@ -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.
@@ -300,19 +293,157 @@ 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", "")
+_curseforge_key_warning_shown = False
+
+
+def get_curseforge_headers() -> Dict[str, str]:
+ """Returns headers for CurseForge API requests."""
+ 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",
+ }
+
+
+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],
+ 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
@@ -327,7 +458,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":
@@ -340,16 +473,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):
@@ -383,17 +522,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)
@@ -459,7 +598,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...")
@@ -538,9 +677,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
@@ -581,9 +724,303 @@ 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")
+ # 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)
+
+ 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",
+ "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",
+ "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 +1030,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 +1045,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",
@@ -626,6 +1074,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}")
@@ -634,8 +1083,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,12 +1095,19 @@ 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
+ pack_version_id=args.pack_version,
)
- except Exception as e:
+ elif args.curseforge_id:
+ process_curseforge_pack(
+ args.curseforge_id,
+ args.output_file,
+ args.dry_run,
+ file_id=args.file_id,
+ )
+ except Exception:
sys.exit(1)