Skip to content
This repository was archived by the owner on Apr 12, 2026. It is now read-only.

Commit 98a42a9

Browse files
committed
feat: add modrinth search, version selection, and GUI enhancements
1 parent afa241b commit 98a42a9

3 files changed

Lines changed: 316 additions & 8 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ python server_pack_builder.py --source /path/to/mods --destination /path/to/serv
2525
Download and filter a Modrinth modpack directly from a URL or slug:
2626

2727
```bash
28+
# Download latest version
2829
python server_pack_builder.py --modrinth-url https://modrinth.com/modpack/fabric-boosted
30+
31+
# Download specific version
32+
python server_pack_builder.py --modrinth-url fabric-boosted --pack-version "1.2.3"
2933
```
3034

3135
### GUI Mode
@@ -47,12 +51,15 @@ python server_pack_builder.py --gui
4751

4852
#### Modrinth Mode
4953
- `--modrinth-url`, `-m`: Modrinth Modpack URL or Slug.
54+
- `--pack-version`: Specific version ID or Number to download (overrides latest).
5055
- `--output-file`, `-o`: Output path for the generated `.mrpack` (optional, defaults to `{PackName}-server.mrpack`).
5156

5257
## Features
5358

5459
- **Client-Side Filtering**: Automatically detects and removes client-only mods by inspecting JAR metadata.
5560
- **Modrinth Integration**: Downloads, filters, and repacks `.mrpack` files.
61+
- **Modrinth Search**: Search for modpacks and view their details/logos within the GUI.
62+
- **Version Selection**: Choose between the latest release or specific versions of a modpack.
5663
- **Multi-Loader Support**: Works with Fabric and Forge mod loaders.
5764
- **Overrides Preservation**: Keeps configuration and other data from the modpack's `overrides` folder.
5865

@@ -64,4 +71,3 @@ For linting and testing:
6471
python -m black . && python -m isort . && python -m flake8 . && python -m mypy .
6572
python -m pytest
6673
```
67-

gui.py

Lines changed: 256 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
22
import sys
33
import os
4+
import requests
45
from typing import Optional
56

6-
from PyQt6.QtCore import QObject, QThread, pyqtSignal, Qt
7+
from PyQt6.QtCore import QObject, QThread, pyqtSignal, Qt, QSize
78
from PyQt6.QtWidgets import (
89
QApplication,
910
QMainWindow,
@@ -21,8 +22,12 @@
2122
QGroupBox,
2223
QStatusBar,
2324
QMessageBox,
25+
QDialog,
26+
QListWidget,
27+
QListWidgetItem,
28+
QComboBox,
2429
)
25-
from PyQt6.QtGui import QFont, QIcon, QPalette, QColor
30+
from PyQt6.QtGui import QFont, QIcon, QPalette, QColor, QImage, QPixmap
2631

2732
import server_pack_builder
2833

@@ -41,6 +46,182 @@ def emit(self, record):
4146

4247
# --- Worker Thread ---
4348

49+
class SearchWorker(QThread):
50+
results_found = pyqtSignal(list)
51+
error_occurred = pyqtSignal(str)
52+
53+
def __init__(self, query: str):
54+
super().__init__()
55+
self.query = query
56+
57+
def run(self):
58+
try:
59+
results = server_pack_builder.search_modrinth_modpacks(self.query)
60+
self.results_found.emit(results)
61+
except Exception as e:
62+
self.error_occurred.emit(str(e))
63+
64+
class VersionsWorker(QThread):
65+
versions_found = pyqtSignal(list)
66+
67+
def __init__(self, slug: str):
68+
super().__init__()
69+
self.slug = slug
70+
71+
def run(self):
72+
try:
73+
versions = server_pack_builder.get_modrinth_versions(self.slug)
74+
self.versions_found.emit(versions)
75+
except Exception:
76+
self.versions_found.emit([])
77+
78+
class ImageLoader(QThread):
79+
image_loaded = pyqtSignal(str, QImage) # url, image
80+
81+
def __init__(self, url: str):
82+
super().__init__()
83+
self.url = url
84+
85+
def run(self):
86+
try:
87+
# Use a session or simple get
88+
response = requests.get(self.url, timeout=5)
89+
if response.status_code == 200:
90+
image = QImage()
91+
image.loadFromData(response.content)
92+
self.image_loaded.emit(self.url, image)
93+
except Exception:
94+
pass # Fail silently for icons
95+
96+
class ModrinthSearchDialog(QDialog):
97+
def __init__(self, parent=None):
98+
super().__init__(parent)
99+
self.setWindowTitle("Search Modrinth Modpacks")
100+
self.resize(700, 500)
101+
self.selected_slug = None
102+
self.image_threads = {} # Keep references to prevent GC
103+
104+
# UI Setup
105+
layout = QVBoxLayout(self)
106+
107+
# Search Bar
108+
search_layout = QHBoxLayout()
109+
self.search_input = QLineEdit()
110+
self.search_input.setPlaceholderText("Search for modpacks (e.g., 'Better MC')...")
111+
self.search_input.returnPressed.connect(self.do_search)
112+
113+
self.btn_search = QPushButton("Search")
114+
self.btn_search.clicked.connect(self.do_search)
115+
116+
search_layout.addWidget(self.search_input)
117+
search_layout.addWidget(self.btn_search)
118+
layout.addLayout(search_layout)
119+
120+
# Results List
121+
self.list_widget = QListWidget()
122+
self.list_widget.setIconSize(QSize(64, 64))
123+
self.list_widget.itemDoubleClicked.connect(self.accept_selection)
124+
layout.addWidget(self.list_widget)
125+
126+
# Buttons
127+
btn_layout = QHBoxLayout()
128+
self.btn_select = QPushButton("Select")
129+
self.btn_select.clicked.connect(self.accept_selection)
130+
self.btn_cancel = QPushButton("Cancel")
131+
self.btn_cancel.clicked.connect(self.reject)
132+
133+
btn_layout.addStretch()
134+
btn_layout.addWidget(self.btn_select)
135+
btn_layout.addWidget(self.btn_cancel)
136+
layout.addLayout(btn_layout)
137+
138+
self.worker = None
139+
140+
def do_search(self):
141+
query = self.search_input.text().strip()
142+
if not query:
143+
return
144+
145+
self.list_widget.clear()
146+
self.btn_search.setEnabled(False)
147+
self.list_widget.addItem("Searching...")
148+
149+
# Cancel previous worker if any
150+
if self.worker and self.worker.isRunning():
151+
self.worker.terminate()
152+
self.worker.wait()
153+
154+
self.worker = SearchWorker(query)
155+
self.worker.results_found.connect(self.populate_results)
156+
self.worker.error_occurred.connect(self.handle_error)
157+
self.worker.finished.connect(lambda: self.btn_search.setEnabled(True))
158+
self.worker.start()
159+
160+
def handle_error(self, error_msg):
161+
self.list_widget.clear()
162+
self.list_widget.addItem(f"Error: {error_msg}")
163+
164+
def populate_results(self, hits):
165+
self.list_widget.clear()
166+
if not hits:
167+
self.list_widget.addItem("No results found.")
168+
return
169+
170+
for hit in hits:
171+
title = hit.get("title", "Unknown")
172+
author = hit.get("author", "Unknown")
173+
slug = hit.get("slug", "")
174+
desc = hit.get("description", "")
175+
icon_url = hit.get("icon_url", "")
176+
177+
# Format text
178+
item_text = f"{title} ({author})\n{desc}"
179+
item = QListWidgetItem(item_text)
180+
item.setData(Qt.ItemDataRole.UserRole, slug)
181+
item.setToolTip(f"Slug: {slug}\n{desc}")
182+
183+
# Placeholder icon
184+
placeholder = QPixmap(64, 64)
185+
placeholder.fill(QColor("gray"))
186+
item.setIcon(QIcon(placeholder))
187+
188+
self.list_widget.addItem(item)
189+
190+
# Load icon if available
191+
if icon_url:
192+
loader = ImageLoader(icon_url)
193+
# Use a closure or default arg to capture 'item' correctly?
194+
# Actually, ImageLoader emits url, we can map url back to item,
195+
# OR we can pass item to ImageLoader (but QThread shouldn't touch UI directly).
196+
# Safer: connect signal to a slot that updates the item.
197+
# Since we loop, we need to know WHICH item to update.
198+
# I'll just store the item in a map keyed by URL? No, multiple packs might share icon? Unlikely.
199+
# Better: Make a custom slot or lambda.
200+
201+
# We need to be careful with lambdas in loops.
202+
loader.image_loaded.connect(lambda u, i, it=item: self.update_icon(it, i))
203+
204+
# Keep reference
205+
self.image_threads[icon_url] = loader
206+
loader.finished.connect(lambda u=icon_url: self.cleanup_thread(u))
207+
loader.start()
208+
209+
def update_icon(self, item, image):
210+
if not image.isNull():
211+
item.setIcon(QIcon(QPixmap.fromImage(image)))
212+
213+
def cleanup_thread(self, url):
214+
if url in self.image_threads:
215+
del self.image_threads[url]
216+
217+
def accept_selection(self):
218+
item = self.list_widget.currentItem()
219+
if item:
220+
slug = item.data(Qt.ItemDataRole.UserRole)
221+
if slug:
222+
self.selected_slug = slug
223+
self.accept()
224+
44225
class WorkerThread(QThread):
45226
"""Runs the long-running CLI tasks in a background thread."""
46227

@@ -80,6 +261,7 @@ def run(self):
80261
modrinth_url=self.kwargs["url"],
81262
output_file=self.kwargs["output"],
82263
dry_run=self.kwargs["dry_run"],
264+
pack_version_id=self.kwargs.get("version_id"),
83265
progress_callback=self.update_progress,
84266
download_callback=self.update_download
85267
)
@@ -165,10 +347,24 @@ def init_ui(self):
165347
url_layout = QHBoxLayout()
166348
self.edit_url = QLineEdit()
167349
self.edit_url.setPlaceholderText("Modrinth URL or Slug (e.g., 'my-pack')")
350+
self.edit_url.editingFinished.connect(self.fetch_versions)
351+
self.btn_search_modrinth = QPushButton("Search...")
352+
self.btn_search_modrinth.clicked.connect(self.open_search_dialog)
353+
168354
url_layout.addWidget(QLabel("URL/Slug:"))
169355
url_layout.addWidget(self.edit_url)
356+
url_layout.addWidget(self.btn_search_modrinth)
170357
mod_layout.addLayout(url_layout)
171358

359+
# Version Selection
360+
version_layout = QHBoxLayout()
361+
self.combo_version = QComboBox()
362+
self.combo_version.addItem("Latest", None)
363+
self.combo_version.setEnabled(False)
364+
version_layout.addWidget(QLabel("Version:"))
365+
version_layout.addWidget(self.combo_version)
366+
mod_layout.addLayout(version_layout)
367+
172368
# Output File
173369
out_layout = QHBoxLayout()
174370
self.edit_output = QLineEdit()
@@ -213,7 +409,7 @@ def init_ui(self):
213409

214410
# Progress Section
215411
progress_layout = QVBoxLayout()
216-
self.lbl_progress = QLabel("Ready")
412+
self.lbl_progress = QLabel("")
217413
self.progress_bar = QProgressBar()
218414
self.progress_bar.setTextVisible(True)
219415
self.progress_bar.setFormat("%p%") # Show percentage
@@ -371,8 +567,10 @@ def start_process(self):
371567
QMessageBox.warning(self, "Invalid Input", "Please enter a Modrinth URL or Slug.")
372568
return
373569

570+
version_id = self.combo_version.currentData()
571+
374572
mode = "modrinth"
375-
kwargs = {"url": url, "output": output, "dry_run": dry_run}
573+
kwargs = {"url": url, "output": output, "dry_run": dry_run, "version_id": version_id}
376574

377575
# Start Thread
378576
self.text_logs.clear()
@@ -412,10 +610,63 @@ def cancel_process(self):
412610
self.progress_bar.setValue(0)
413611
self.progress_bar.setRange(0, 100)
414612
self.progress_bar.setFormat("%p%")
415-
self.lbl_progress.setText("Ready")
613+
self.lbl_progress.setText("")
416614

417615
self.process_finished(False, "Process cancelled.")
418616

617+
def open_search_dialog(self):
618+
dialog = ModrinthSearchDialog(self)
619+
if dialog.exec() == QDialog.DialogCode.Accepted:
620+
if dialog.selected_slug:
621+
self.edit_url.setText(dialog.selected_slug)
622+
self.fetch_versions()
623+
624+
def fetch_versions(self):
625+
slug_or_url = self.edit_url.text().strip()
626+
if not slug_or_url:
627+
return
628+
629+
# Simple check if it looks like a slug/url
630+
if "modrinth.com" in slug_or_url:
631+
# Extract slug from URL if possible, otherwise let backend handle it?
632+
# get_modrinth_project_version is in backend.
633+
# We can't easily call it here without importing or duplicating.
634+
# We can just try to search versions for the slug/ID extracted.
635+
# For now, let's just pass the text. If it's a URL, the backend might fail to find versions
636+
# unless we extract the slug first.
637+
pass
638+
639+
# Use backend helper to extract slug if needed?
640+
slug, _ = server_pack_builder.get_modrinth_project_version(slug_or_url)
641+
642+
self.combo_version.clear()
643+
self.combo_version.addItem("Loading...", None)
644+
self.combo_version.setEnabled(False)
645+
646+
# We need a worker for this so GUI doesn't freeze
647+
self.version_worker = VersionsWorker(slug)
648+
self.version_worker.versions_found.connect(self.populate_versions)
649+
self.version_worker.start()
650+
651+
def populate_versions(self, versions):
652+
self.combo_version.clear()
653+
self.combo_version.addItem("Latest", None)
654+
655+
if versions:
656+
for v in versions:
657+
name = v.get("name", "Unknown")
658+
vid = v.get("id")
659+
game_versions = v.get("game_versions", [])
660+
loaders = v.get("loaders", [])
661+
662+
display = f"{name} ({', '.join(game_versions)}) - {', '.join(loaders)}"
663+
self.combo_version.addItem(display, vid)
664+
665+
self.combo_version.setEnabled(True)
666+
else:
667+
self.combo_version.addItem("No versions found or invalid slug", None)
668+
self.combo_version.setEnabled(True) # Allow user to still try "Latest" if they think it's right
669+
419670
def run_gui():
420671
app = QApplication(sys.argv)
421672
window = MainWindow()

0 commit comments

Comments
 (0)