11import logging
22import sys
33import os
4+ import requests
45from typing import Optional
56
6- from PyQt6 .QtCore import QObject , QThread , pyqtSignal , Qt
7+ from PyQt6 .QtCore import QObject , QThread , pyqtSignal , Qt , QSize
78from PyQt6 .QtWidgets import (
89 QApplication ,
910 QMainWindow ,
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
2732import 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+
44225class 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+
419670def run_gui ():
420671 app = QApplication (sys .argv )
421672 window = MainWindow ()
0 commit comments