diff --git a/__pycache__/main.cpython-310.pyc b/__pycache__/main.cpython-310.pyc new file mode 100644 index 00000000..b3a06fbc Binary files /dev/null and b/__pycache__/main.cpython-310.pyc differ diff --git a/core/__pycache__/matching_engine.cpython-310.pyc b/core/__pycache__/matching_engine.cpython-310.pyc index 7a24895d..7c312cf3 100644 Binary files a/core/__pycache__/matching_engine.cpython-310.pyc and b/core/__pycache__/matching_engine.cpython-310.pyc differ diff --git a/core/__pycache__/matching_engine.cpython-312.pyc b/core/__pycache__/matching_engine.cpython-312.pyc index bef49313..02ae65c6 100644 Binary files a/core/__pycache__/matching_engine.cpython-312.pyc and b/core/__pycache__/matching_engine.cpython-312.pyc differ diff --git a/core/__pycache__/soulseek_client.cpython-310.pyc b/core/__pycache__/soulseek_client.cpython-310.pyc new file mode 100644 index 00000000..c84dcf89 Binary files /dev/null and b/core/__pycache__/soulseek_client.cpython-310.pyc differ diff --git a/core/__pycache__/soulseek_client.cpython-312.pyc b/core/__pycache__/soulseek_client.cpython-312.pyc index 71dc380b..4025bf6d 100644 Binary files a/core/__pycache__/soulseek_client.cpython-312.pyc and b/core/__pycache__/soulseek_client.cpython-312.pyc differ diff --git a/core/matching_engine.py b/core/matching_engine.py index 39deb489..76fb95f3 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -52,7 +52,8 @@ class MusicMatchingEngine: """ if not text: return "" - text = re.sub(r'koяn', 'korn', text, flags=re.IGNORECASE) + # Handle Korn/KoЯn variations - both uppercase Я (U+042F) and lowercase я (U+044F) + text = re.sub(r'ko[яЯ]n', 'korn', text, flags=re.IGNORECASE) text = unidecode(text) text = text.lower() diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 5df5400b..7ff216e9 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -203,6 +203,7 @@ class SoulseekClient: self.base_url: Optional[str] = None self.api_key: Optional[str] = None self.download_path: Path = Path("./downloads") + self.active_searches: Dict[str, bool] = {} # search_id -> still_active self._setup_client() def _setup_client(self): @@ -566,6 +567,9 @@ class SoulseekClient: logger.info(f"Search initiated with ID: {search_id}") + # Track this search as active + self.active_searches[search_id] = True + # Poll for results - process and emit results immediately when found all_responses = [] all_tracks = [] @@ -574,6 +578,11 @@ class SoulseekClient: max_polls = int(timeout / poll_interval) # 20 attempts over 30 seconds for poll_count in range(max_polls): + # Check if search was cancelled + if search_id not in self.active_searches: + logger.info(f"Search {search_id} was cancelled, stopping") + return [], [] + logger.debug(f"Polling for results (attempt {poll_count + 1}/{max_polls}) - elapsed: {poll_count * poll_interval:.1f}s") # Get current search responses @@ -627,6 +636,10 @@ class SoulseekClient: except Exception as e: logger.error(f"Error searching: {e}") return [], [] + finally: + # Remove from active searches when done + if 'search_id' in locals() and search_id in self.active_searches: + del self.active_searches[search_id] async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]: if not self.base_url: @@ -1338,9 +1351,26 @@ class SoulseekClient: """Check if slskd is configured (has base_url)""" return self.base_url is not None + async def cancel_all_searches(self): + """Cancel all active searches""" + if not self.active_searches: + return + + logger.info(f"Cancelling {len(self.active_searches)} active searches...") + for search_id in list(self.active_searches.keys()): + try: + # Delete the search via API + await self._make_request('DELETE', f'searches/{search_id}') + logger.debug(f"Cancelled search {search_id}") + except Exception as e: + logger.warning(f"Could not cancel search {search_id}: {e}") + + # Mark all searches as cancelled + self.active_searches.clear() + async def close(self): - # No persistent session to close - each request creates its own session - pass + # Cancel any active searches before closing + await self.cancel_all_searches() def __del__(self): # No persistent session to clean up diff --git a/main.py b/main.py index 8cb6c64e..81a8fd34 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ import asyncio import time from pathlib import Path from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QStackedWidget -from PyQt6.QtCore import QThread, pyqtSignal, QTimer +from PyQt6.QtCore import QThread, pyqtSignal, QTimer, QThreadPool from PyQt6.QtGui import QFont, QPalette, QColor from config.settings import config_manager @@ -58,7 +58,7 @@ class ServiceStatusThread(QThread): def stop(self): self.running = False self.quit() - self.wait() + self.wait(2000) # Wait max 2 seconds class MainWindow(QMainWindow): def __init__(self): @@ -321,6 +321,27 @@ class MainWindow(QMainWindow): logger.info("Cleaning up Dashboard page threads...") self.dashboard_page.cleanup_threads() + # Stop other page threads and background tasks + if hasattr(self, 'artists_page') and self.artists_page: + logger.info("Cleaning up Artists page threads...") + if hasattr(self.artists_page, 'cleanup_threads'): + self.artists_page.cleanup_threads() + + if hasattr(self, 'sync_page') and self.sync_page: + logger.info("Cleaning up Sync page threads...") + if hasattr(self.sync_page, 'cleanup_threads'): + self.sync_page.cleanup_threads() + + if hasattr(self, 'downloads_page') and self.downloads_page: + logger.info("Cleaning up Downloads page threads...") + if hasattr(self.downloads_page, 'cleanup_threads'): + self.downloads_page.cleanup_threads() + + # Stop all QThreadPool tasks + logger.info("Stopping global thread pool...") + QThreadPool.globalInstance().clear() + QThreadPool.globalInstance().waitForDone(1000) # Wait max 1 second + # Stop status monitoring thread if self.status_thread: logger.info("Stopping status monitoring thread...") @@ -334,8 +355,16 @@ class MainWindow(QMainWindow): # Close Soulseek client try: logger.info("Closing Soulseek client...") - loop = asyncio.get_event_loop() - loop.run_until_complete(self.soulseek_client.close()) + # Use modern asyncio approach instead of deprecated get_event_loop + try: + loop = asyncio.get_running_loop() + # Create a new task to close the client + task = asyncio.create_task(self.soulseek_client.close()) + # Wait for it to complete + asyncio.run_coroutine_threadsafe(self.soulseek_client.close(), loop).result(timeout=3.0) + except RuntimeError: + # No running loop, create new one + asyncio.run(self.soulseek_client.close()) except Exception as e: logger.error(f"Error closing Soulseek client: {e}") diff --git a/ui/pages/__pycache__/artists.cpython-310.pyc b/ui/pages/__pycache__/artists.cpython-310.pyc new file mode 100644 index 00000000..af7f2152 Binary files /dev/null and b/ui/pages/__pycache__/artists.cpython-310.pyc differ diff --git a/ui/pages/__pycache__/artists.cpython-312.pyc b/ui/pages/__pycache__/artists.cpython-312.pyc index b4276721..3ecfe81d 100644 Binary files a/ui/pages/__pycache__/artists.cpython-312.pyc and b/ui/pages/__pycache__/artists.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/dashboard.cpython-310.pyc b/ui/pages/__pycache__/dashboard.cpython-310.pyc index d44486db..0c94225c 100644 Binary files a/ui/pages/__pycache__/dashboard.cpython-310.pyc and b/ui/pages/__pycache__/dashboard.cpython-310.pyc differ diff --git a/ui/pages/__pycache__/downloads.cpython-310.pyc b/ui/pages/__pycache__/downloads.cpython-310.pyc new file mode 100644 index 00000000..6ab93ef1 Binary files /dev/null and b/ui/pages/__pycache__/downloads.cpython-310.pyc differ diff --git a/ui/pages/__pycache__/downloads.cpython-312.pyc b/ui/pages/__pycache__/downloads.cpython-312.pyc index fcf97c1b..385b75a3 100644 Binary files a/ui/pages/__pycache__/downloads.cpython-312.pyc and b/ui/pages/__pycache__/downloads.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/sync.cpython-310.pyc b/ui/pages/__pycache__/sync.cpython-310.pyc new file mode 100644 index 00000000..59a7e8ea Binary files /dev/null and b/ui/pages/__pycache__/sync.cpython-310.pyc differ diff --git a/ui/pages/__pycache__/sync.cpython-312.pyc b/ui/pages/__pycache__/sync.cpython-312.pyc index b3f5f387..467fa8be 100644 Binary files a/ui/pages/__pycache__/sync.cpython-312.pyc and b/ui/pages/__pycache__/sync.cpython-312.pyc differ diff --git a/ui/pages/artists.py b/ui/pages/artists.py index 8bfe1ffb..2f5598ac 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -2884,9 +2884,20 @@ class DownloadMissingAlbumTracksModal(QDialog): asyncio.set_event_loop(loop) search_result = loop.run_until_complete(self.soulseek_client.search(self.query)) results_list = search_result[0] if isinstance(search_result, tuple) and search_result else [] - self.signals.search_completed.emit(results_list, self.query) + + # Check if signals object is still valid before emitting + try: + self.signals.search_completed.emit(results_list, self.query) + except RuntimeError: + # Qt objects deleted during shutdown, ignore + logger.debug(f"Search completed for '{self.query}' but UI already closed") + except Exception as e: - self.signals.search_failed.emit(self.query, str(e)) + try: + self.signals.search_failed.emit(self.query, str(e)) + except RuntimeError: + # Qt objects deleted during shutdown, ignore + logger.debug(f"Search failed for '{self.query}' but UI already closed: {e}") finally: if loop: loop.close() diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index 07a13cf4..322ec0f8 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -970,9 +970,20 @@ class DownloadMissingWishlistTracksModal(QDialog): asyncio.set_event_loop(loop) search_result = loop.run_until_complete(self.soulseek_client.search(self.query)) results_list = search_result[0] if isinstance(search_result, tuple) and search_result else [] - self.signals.search_completed.emit(results_list, self.query) + + # Check if signals object is still valid before emitting + try: + self.signals.search_completed.emit(results_list, self.query) + except RuntimeError: + # Qt objects deleted during shutdown, ignore + logger.debug(f"Search completed for '{self.query}' but UI already closed") + except Exception as e: - self.signals.search_failed.emit(self.query, str(e)) + try: + self.signals.search_failed.emit(self.query, str(e)) + except RuntimeError: + # Qt objects deleted during shutdown, ignore + logger.debug(f"Search failed for '{self.query}' but UI already closed: {e}") finally: if loop: loop.close() diff --git a/ui/pages/downloads.py b/ui/pages/downloads.py index 6d348558..5ba5d3b0 100644 --- a/ui/pages/downloads.py +++ b/ui/pages/downloads.py @@ -564,7 +564,7 @@ class SpotifyMatchingModal(QDialog): self.skipped_matching = False # Clean up the image download pool self.image_download_pool.clear() - self.image_download_pool.waitForDone(-1) # Wait indefinitely for tasks to finish + self.image_download_pool.waitForDone(5000) # Wait max 5 seconds for tasks to finish self.cancelled.emit() super().reject() @@ -9085,21 +9085,20 @@ class DownloadsPage(QWidget): try: # Try to get existing event loop first try: - loop = asyncio.get_event_loop() - if loop.is_running(): - # If loop is running, we need to run in a thread - def run_in_thread(): - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - try: - new_loop.run_until_complete(self._cancel_current_streaming_download()) - finally: - new_loop.close() - - thread = threading.Thread(target=run_in_thread) - thread.start() - thread.join(timeout=5.0) # Wait max 5 seconds - return + loop = asyncio.get_running_loop() + # Loop is already running, we need to run in a thread + def run_in_thread(): + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + try: + new_loop.run_until_complete(self._cancel_current_streaming_download()) + finally: + new_loop.close() + + thread = threading.Thread(target=run_in_thread) + thread.start() + thread.join(timeout=5.0) # Wait max 5 seconds + return except RuntimeError: # No event loop in current thread pass diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 39749b3d..6d322149 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -4814,9 +4814,20 @@ class DownloadMissingTracksModal(QDialog): asyncio.set_event_loop(loop) search_result = loop.run_until_complete(self.soulseek_client.search(self.query)) results_list = search_result[0] if isinstance(search_result, tuple) and search_result else [] - self.signals.search_completed.emit(results_list, self.query) + + # Check if signals object is still valid before emitting + try: + self.signals.search_completed.emit(results_list, self.query) + except RuntimeError: + # Qt objects deleted during shutdown, ignore + logger.debug(f"Search completed for '{self.query}' but UI already closed") + except Exception as e: - self.signals.search_failed.emit(self.query, str(e)) + try: + self.signals.search_failed.emit(self.query, str(e)) + except RuntimeError: + # Qt objects deleted during shutdown, ignore + logger.debug(f"Search failed for '{self.query}' but UI already closed: {e}") finally: if loop: loop.close() diff --git a/utils/__pycache__/logging_config.cpython-312.pyc b/utils/__pycache__/logging_config.cpython-312.pyc index fedb4143..58774e6f 100644 Binary files a/utils/__pycache__/logging_config.cpython-312.pyc and b/utils/__pycache__/logging_config.cpython-312.pyc differ