From 6154ed9519eddc58cedda91f36adc11dcf4ec943 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Thu, 10 Jul 2025 13:52:13 -0700 Subject: [PATCH] working better --- .spotify_cache | 2 +- .../soulseek_client.cpython-312.pyc | Bin 32643 -> 33141 bytes core/soulseek_client.py | 51 +- logs/app.log | 1942 +++++++++++++++++ .../__pycache__/downloads.cpython-312.pyc | Bin 109014 -> 137915 bytes ui/pages/downloads.py | 760 ++++++- 6 files changed, 2647 insertions(+), 108 deletions(-) diff --git a/.spotify_cache b/.spotify_cache index a2512cc5..093fe8b0 100644 --- a/.spotify_cache +++ b/.spotify_cache @@ -1 +1 @@ -{"access_token": "BQBe_MJ7AUBigKGLrL5cDrQcNOcfu9QBWJfHn1YlwGrK7MHkoUaG0q_W6LGKB1qLKTLfqfLvZxBkiU57KA-PfRNM_TLmNOVrje3qqBsy3ILWlO85wNVqK2QEFVEOPdXK9nVyq_UlE9Olo0HiCYRmDShhJwghY7qCAHliDqO8ag2D0f8hZtvf_cf4pDNEq9BRtnGmlOsIMEefpexFt-d86t89Y8qtRQXM_70TsMiQMQEW1XULWWp9ppWAGcmzTYuX", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752167413, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"} \ No newline at end of file +{"access_token": "BQAt2G_W1YOi76MMD9eqwcCyhCjip3RwAvqpGqygiqam8Z8f7EUU6Ow-G30LzdA-ZkhhdkdFiTOTZr1e4gzNhKPA2UFRS182N-7-QzyWEtBoHKmJm0ha8yosWEhb3MtZU5Ei7kYZ9Vqz6oP1NfAWxxQ86VH7eXbNe_XaaDw1SGv59XCwHm-EulvGDweyBIegm4akftwQ_6m57lyvtz-r7hTZ-KCJc72webluIBRjwLxn59TJ1NFq3vOwoZLmpZo6", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752183412, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"} \ No newline at end of file diff --git a/core/__pycache__/soulseek_client.cpython-312.pyc b/core/__pycache__/soulseek_client.cpython-312.pyc index 41e9183fe42564d7e36472bb1bad8b8309bd4f94..a2a64ed1cf4e700b541edb0801a72caf2e010427 100644 GIT binary patch delta 1578 zcmZvaeM}Q)9LJxh@7HT7SSW>~Ld#1*KwfkdRCF*Ppny21k!?g7R}cdfo;$5*Z_P9& zEHkm$vt$rwn3)MRY|^E1VaxvTpAnb6(VPo6vPE3X662B)z$|mg-1AV&7Plw4&;6e7 z@9p_LcjuqLN9R;YUzyEDK$b0a_;^GA$4OrvVw1`CJb6~nsc~I$Hin0HWB*~ZDFnO9 z0g%)I^$9H=*=of%4zM~&v$6fG#GD+^HUJW*CULreE*_Vy>jTp&+Jvb3fW85AJs_5W z*Go*m5RcIsc+Ip5Cs$|UTdV<(dD7g>G>qpUHE)!dp=_QF7)}AiiELa4Ad#KR^7=%F zM3%&e=4az9CfX&gCUR5E|I#!Y>iT(}MsA$alEhn{&9@}~kCyUZEn`#SWFK0mnRsQ% z7*L(C;E|(Nw=v;`;Nn9KxxtNVi_+WCd!*`PtBAUXgkCQa%6OlDXu#La3n+ zPZBIL8*LYvlY$o=<-1A2^$EX?`=3sjKN=tKEtHgZDY;%teldb5n`6?nPV->2C%tc6=a$#LX5)YP1a7- zhBG5C%3O8Gplo(e4o(b)+h!}}&AUVFlCxwL*itG&ElO7YWZOhr*d3{qv#LUkOZi(? zfHkdRoLR~(7&p*DL0Tz2;*z=N7Py@;Zl}!EE^y5;uK6~1hdZRWOD38XhdW#ob8P?7 zT@vn`>AK!EyW^H1m+q6@t>ex2on=aP;iNPngNbk-<^6*K1R=J~4T+fKQ#H4&b43(Br~rFi>H z?e*H(Y`M5)&1!UIj_a0fAj7p_FO1m>!?y43!sE=TgN@u zw!J_4!R~oi<$PuPe8&DerVhoLHE+sVZiB$y4j->|Kz!}xq67QMhdF0y+5_KCYqEeT z*ksa#^~9QiM2j?X#GGTPJ!gW{wlGb*)N}TXCXafq0#dtDOT8W~wRfqTqLr_VK=>ef z?bWZTz^)8Z{SbPUMjmoA{RBU`FsOt zfhPS#u|)AR#UB)ZQVdc26%_*>m{P8_g;T>rv7*{lKQUCw| delta 1143 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 f&%2e0GUPW;IWN)s{1pA#Sg 0: + # File exists and has content - streaming ready + self.streaming_finished.emit(f"Stream ready: {self.search_result.filename}", self.search_result) + self.temp_file_path = temp_path + print(f"✓ Stream file ready for playback: {temp_path}") + break + else: + # Still downloading, wait a bit more + time.sleep(poll_interval) + else: + # Timed out waiting for file + self.streaming_failed.emit("Stream download timed out", self.search_result) + + else: + self.streaming_failed.emit("Streaming failed to start", self.search_result) + + except Exception as e: + if not self._stop_requested: + self.streaming_failed.emit(str(e), self.search_result) + finally: + # Ensure proper cleanup + if loop: + try: + # Close any remaining tasks + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + + loop.close() + except Exception as e: + print(f"Error cleaning up streaming event loop: {e}") + + async def _do_stream_download(self): + """Perform the streaming download using normal download mechanism""" + # Use the same download mechanism as regular downloads + # The temp location will be handled by the download path configuration + return await self.soulseek_client.download( + self.search_result.username, + self.search_result.filename, + self.search_result.size + ) + + def stop(self): + """Stop the streaming gracefully""" + self._stop_requested = True + class SearchResultItem(QFrame): download_requested = pyqtSignal(object) # SearchResult object + stream_requested = pyqtSignal(object) # SearchResult object for streaming expansion_requested = pyqtSignal(object) # Signal when this item wants to expand def __init__(self, search_result, parent=None): @@ -286,7 +446,6 @@ class SearchResultItem(QFrame): stop:0 rgba(50, 50, 50, 0.95), stop:1 rgba(40, 40, 40, 0.98)); border: 1px solid rgba(29, 185, 84, 0.7); - cursor: pointer; } """) @@ -325,7 +484,38 @@ class SearchResultItem(QFrame): # Create both compact and expanded content but show only one self.create_persistent_content(primary_info) - # Right section: Always-visible download button + # Right section: Play and download buttons + buttons_layout = QHBoxLayout() + buttons_layout.setSpacing(4) + + # Play button for streaming preview + self.play_btn = QPushButton("▶️") + self.play_btn.setFixedSize(36, 36) + self.play_btn.clicked.connect(self.request_stream) + self.play_btn.setStyleSheet(""" + QPushButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(255, 193, 7, 0.9), + stop:1 rgba(255, 152, 0, 0.9)); + border: none; + border-radius: 18px; + color: #000000; + font-size: 14px; + font-weight: bold; + } + QPushButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(255, 213, 79, 1.0), + stop:1 rgba(255, 171, 64, 1.0)); + } + QPushButton:pressed { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(255, 152, 0, 1.0), + stop:1 rgba(245, 124, 0, 1.0)); + } + """) + + # Download button self.download_btn = QPushButton("⬇️") self.download_btn.setFixedSize(36, 36) self.download_btn.clicked.connect(self.request_download) @@ -356,8 +546,11 @@ class SearchResultItem(QFrame): left_section.addWidget(music_icon) left_section.addWidget(self.content_widget, 1) + buttons_layout.addWidget(self.play_btn) + buttons_layout.addWidget(self.download_btn) + layout.addLayout(left_section, 1) - layout.addWidget(self.download_btn) + layout.addLayout(buttons_layout) def create_persistent_content(self, primary_info): """Create both compact and expanded content with visibility control""" @@ -662,6 +855,33 @@ class SearchResultItem(QFrame): self.download_btn.setEnabled(False) self.download_requested.emit(self.search_result) + def request_stream(self): + """Request streaming of this audio file""" + # Check if file is a valid audio type + audio_extensions = ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav'] + filename_lower = self.search_result.filename.lower() + + is_audio = any(filename_lower.endswith(ext) for ext in audio_extensions) + + if is_audio: + # Change button state to indicate streaming + original_text = self.play_btn.text() + self.play_btn.setText("⏸️") # Pause icon to indicate playing + self.play_btn.setEnabled(False) + + # Emit streaming request + self.stream_requested.emit(self.search_result) + + # Reset button after a delay (basic state management) + QTimer.singleShot(2000, lambda: self.reset_play_state(original_text)) + else: + print(f"Cannot stream non-audio file: {self.search_result.filename}") + + def reset_play_state(self, original_text="▶️"): + """Reset the play button state""" + self.play_btn.setText(original_text) + self.play_btn.setEnabled(True) + def reset_download_state(self): """Reset the download button state""" self.is_downloading = False @@ -670,7 +890,8 @@ class SearchResultItem(QFrame): class DownloadItem(QFrame): def __init__(self, title: str, artist: str, status: str, progress: int = 0, - file_size: int = 0, download_speed: int = 0, file_path: str = "", parent=None): + file_size: int = 0, download_speed: int = 0, file_path: str = "", + download_id: str = "", soulseek_client=None, parent=None): super().__init__(parent) self.title = title self.artist = artist @@ -679,6 +900,8 @@ class DownloadItem(QFrame): self.file_size = file_size self.download_speed = download_speed self.file_path = file_path + self.download_id = download_id # Track download ID for cancellation + self.soulseek_client = soulseek_client # For cancellation functionality self.setup_ui() def setup_ui(self): @@ -770,11 +993,11 @@ class DownloadItem(QFrame): progress_layout = QVBoxLayout() progress_layout.setSpacing(5) - # Progress bar - progress_bar = QProgressBar() - progress_bar.setFixedHeight(6) - progress_bar.setValue(self.progress) - progress_bar.setStyleSheet(""" + # Progress bar - Store reference for safe updates + self.progress_bar = QProgressBar() + self.progress_bar.setFixedHeight(6) + self.progress_bar.setValue(self.progress) + self.progress_bar.setStyleSheet(""" QProgressBar { border: none; border-radius: 3px; @@ -786,29 +1009,42 @@ class DownloadItem(QFrame): } """) - # Status text - status_text = f"{self.status.title()}" - if self.status == "downloading": + # Status text - Store reference for safe updates with clean mapping + status_mapping = { + "completed, succeeded": "Finished", + "completed, cancelled": "Cancelled", + "completed": "Finished", + "cancelled": "Cancelled", + "downloading": "Downloading", + "failed": "Failed", + "queued": "Queued" + } + + clean_status = status_mapping.get(self.status.lower(), self.status.title()) + status_text = clean_status + + if self.status.lower() in ["downloading", "queued"]: status_text += f" - {self.progress}%" - status_label = QLabel(status_text) - status_label.setFont(QFont("Arial", 9)) - status_label.setStyleSheet("color: #b3b3b3;") + self.status_label = QLabel(status_text) + self.status_label.setFont(QFont("Arial", 9)) + self.status_label.setStyleSheet("color: #b3b3b3;") - progress_layout.addWidget(progress_bar) - progress_layout.addWidget(status_label) + progress_layout.addWidget(self.progress_bar) + progress_layout.addWidget(self.status_label) # Action buttons section actions_layout = QVBoxLayout() actions_layout.setSpacing(4) # Primary action button - action_btn = QPushButton() - action_btn.setFixedSize(80, 28) + self.action_btn = QPushButton() + self.action_btn.setFixedSize(80, 28) if self.status == "downloading": - action_btn.setText("Cancel") - action_btn.setStyleSheet(""" + self.action_btn.setText("Cancel") + self.action_btn.clicked.connect(self.cancel_download) + self.action_btn.setStyleSheet(""" QPushButton { background: transparent; border: 1px solid #e22134; @@ -823,8 +1059,9 @@ class DownloadItem(QFrame): } """) elif self.status == "failed": - action_btn.setText("Retry") - action_btn.setStyleSheet(""" + self.action_btn.setText("Retry") + self.action_btn.clicked.connect(self.retry_download) + self.action_btn.setStyleSheet(""" QPushButton { background: transparent; border: 1px solid #1db954; @@ -839,8 +1076,9 @@ class DownloadItem(QFrame): } """) else: - action_btn.setText("Details") - action_btn.setStyleSheet(""" + self.action_btn.setText("Details") + self.action_btn.clicked.connect(self.show_details) + self.action_btn.setStyleSheet(""" QPushButton { background: transparent; border: 1px solid #b3b3b3; @@ -874,7 +1112,7 @@ class DownloadItem(QFrame): } """) - actions_layout.addWidget(action_btn) + actions_layout.addWidget(self.action_btn) if self.status == "completed" and self.file_path: actions_layout.addWidget(location_btn) @@ -923,7 +1161,8 @@ class DownloadItem(QFrame): print(f"Error opening download location: {e}") def update_status(self, status: str, progress: int = None, download_speed: int = None, file_path: str = None): - """Update download item status and refresh UI""" + """SAFE UPDATE: Update download item status without UI destruction""" + # Update properties self.status = status if progress is not None: self.progress = progress @@ -932,19 +1171,100 @@ class DownloadItem(QFrame): if file_path: self.file_path = file_path - # Refresh the UI by recreating it - # Clear current layout - while self.layout().count(): - child = self.layout().takeAt(0) - if child.widget(): - child.widget().deleteLater() - - # Recreate UI with updated values - self.setup_ui() + # SAFE UI UPDATES: Update widgets directly instead of recreating + try: + # Update progress bar safely + if hasattr(self, 'progress_bar') and self.progress_bar: + self.progress_bar.setValue(self.progress) + + # Update status label safely + if hasattr(self, 'status_label') and self.status_label: + # Clean up status text display + status_mapping = { + "completed, succeeded": "Finished", + "completed, cancelled": "Cancelled", + "completed": "Finished", + "cancelled": "Cancelled", + "downloading": "Downloading", + "failed": "Failed", + "queued": "Queued" + } + + clean_status = status_mapping.get(self.status.lower(), self.status.title()) + status_text = clean_status + + if self.status.lower() in ["downloading", "queued"]: + status_text += f" - {self.progress}%" + + self.status_label.setText(status_text) + + except Exception as e: + print(f"Error updating download item UI: {e}") + # Fallback: only recreate if safe update fails + self.setup_ui() + + def cancel_download(self): + """Cancel the download using the SoulseekClient""" + if not self.soulseek_client or not self.download_id: + print(f"Cannot cancel download: missing client or download ID") + return + + try: + # Use async cancellation in a simple way + import asyncio + loop = None + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # Run the cancellation + result = loop.run_until_complete(self.soulseek_client.cancel_download(self.download_id)) + + if result: + print(f"Successfully cancelled download: {self.title}") + self.update_status("cancelled", progress=0) + + # Find the parent TabbedDownloadManager and move to finished tab + parent_widget = self.parent() + while parent_widget: + if hasattr(parent_widget, 'move_to_finished'): + parent_widget.move_to_finished(self) + break + parent_widget = parent_widget.parent() + + else: + print(f"Failed to cancel download: {self.title}") + + except Exception as e: + print(f"Error cancelling download {self.title}: {e}") + + def retry_download(self): + """Retry a failed download""" + # For now, just update status back to downloading + # In a full implementation, this would restart the download + self.update_status("downloading", progress=0) + print(f"Retry requested for: {self.title}") + + def show_details(self): + """Show download details""" + details = f""" +Download Details: +Title: {self.title} +Artist: {self.artist} +Status: {self.status} +Progress: {self.progress}% +File Size: {self.file_size // (1024*1024)}MB +Download ID: {self.download_id} +File Path: {self.file_path} + """ + print(details) class DownloadQueue(QFrame): - def __init__(self, parent=None): + def __init__(self, title="Download Queue", parent=None): super().__init__(parent) + self.queue_title = title self.setup_ui() def setup_ui(self): @@ -964,9 +1284,9 @@ class DownloadQueue(QFrame): header_layout = QHBoxLayout() header_layout.setContentsMargins(0, 0, 0, 0) - title_label = QLabel("Download Queue") - title_label.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold)) - title_label.setStyleSheet(""" + self.title_label = QLabel(self.queue_title) + self.title_label.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold)) + self.title_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.95); font-weight: 600; padding: 0; @@ -979,7 +1299,7 @@ class DownloadQueue(QFrame): padding: 0; """) - header_layout.addWidget(title_label) + header_layout.addWidget(self.title_label) header_layout.addStretch() header_layout.addWidget(queue_count) @@ -1026,14 +1346,15 @@ class DownloadQueue(QFrame): layout.addWidget(queue_scroll) def add_download_item(self, title: str, artist: str, status: str = "queued", - progress: int = 0, file_size: int = 0, download_speed: int = 0, file_path: str = ""): + progress: int = 0, file_size: int = 0, download_speed: int = 0, + file_path: str = "", download_id: str = "", soulseek_client=None): """Add a new download item to the queue""" # Hide empty message if this is the first item if len(self.download_items) == 0: self.empty_message.hide() # Create new download item - item = DownloadItem(title, artist, status, progress, file_size, download_speed, file_path) + item = DownloadItem(title, artist, status, progress, file_size, download_speed, file_path, download_id, soulseek_client) self.download_items.append(item) # Insert before the stretch (which is always last) @@ -1073,6 +1394,117 @@ class DownloadQueue(QFrame): for item in items_to_remove: self.remove_download_item(item) +class TabbedDownloadManager(QTabWidget): + """Tabbed interface for managing active and finished downloads""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + """Setup the tabbed interface with active and finished download queues""" + self.setStyleSheet(""" + QTabWidget::pane { + border: 1px solid #404040; + border-radius: 8px; + background: #282828; + } + QTabWidget::tab-bar { + alignment: center; + } + QTabBar::tab { + background: #404040; + color: #ffffff; + border: 1px solid #606060; + border-bottom: none; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + padding: 6px 12px; + margin-right: 1px; + font-size: 10px; + font-weight: bold; + min-width: 80px; + } + QTabBar::tab:selected { + background: #1db954; + color: #000000; + border: 1px solid #1db954; + } + QTabBar::tab:hover:!selected { + background: #505050; + } + """) + + # Create two download queues with appropriate titles + self.active_queue = DownloadQueue("Active Downloads") + self.finished_queue = DownloadQueue("Finished Downloads") + + # Update the finished queue count label + self.finished_queue.queue_count_label.setText("No finished downloads") + + # Add tabs + self.addTab(self.active_queue, "Download Queue") + self.addTab(self.finished_queue, "Finished Downloads") + + # Set initial tab counts + self.update_tab_counts() + + def add_download_item(self, title: str, artist: str, status: str = "queued", + progress: int = 0, file_size: int = 0, download_speed: int = 0, + file_path: str = "", download_id: str = "", soulseek_client=None): + """Add a new download item to the active queue""" + item = self.active_queue.add_download_item( + title, artist, status, progress, file_size, download_speed, + file_path, download_id, soulseek_client + ) + self.update_tab_counts() + return item + + def move_to_finished(self, download_item): + """Move a download item from active to finished queue""" + if download_item in self.active_queue.download_items: + # Remove from active queue + self.active_queue.remove_download_item(download_item) + + # Add to finished queue + finished_item = self.finished_queue.add_download_item( + title=download_item.title, + artist=download_item.artist, + status=download_item.status, + progress=download_item.progress, + file_size=download_item.file_size, + download_speed=download_item.download_speed, + file_path=download_item.file_path, + download_id=download_item.download_id, + soulseek_client=download_item.soulseek_client + ) + + self.update_tab_counts() + return finished_item + return None + + def update_tab_counts(self): + """Update tab labels with current counts""" + active_count = len(self.active_queue.download_items) + finished_count = len(self.finished_queue.download_items) + + self.setTabText(0, f"Download Queue ({active_count})") + self.setTabText(1, f"Finished Downloads ({finished_count})") + + # Also update the download manager stats if they exist + if hasattr(self.parent(), 'update_download_manager_stats'): + self.parent().update_download_manager_stats(active_count, finished_count) + + def clear_completed_downloads(self): + """Clear completed downloads from the finished queue""" + self.finished_queue.clear_completed_downloads() + self.update_tab_counts() + + @property + def download_items(self): + """Return all download items from active queue for compatibility""" + return self.active_queue.download_items + class DownloadsPage(QWidget): def __init__(self, soulseek_client=None, parent=None): super().__init__(parent) @@ -1081,6 +1513,7 @@ class DownloadsPage(QWidget): self.explore_thread = None # Track API exploration thread self.session_thread = None # Track session info thread self.download_threads = [] # Track active download threads + self.status_update_threads = [] # Track status update threads (CRITICAL FIX) self.search_results = [] self.download_items = [] # Track download items for the queue self.displayed_results = 0 # Track how many results are currently displayed @@ -1405,16 +1838,16 @@ class DownloadsPage(QWidget): stats_layout.setContentsMargins(12, 10, 12, 10) stats_layout.setSpacing(6) - active_downloads = QLabel("• Active Downloads: 0") - active_downloads.setFont(QFont("Arial", 10)) - active_downloads.setStyleSheet("color: rgba(255, 255, 255, 0.8);") + self.active_downloads_label = QLabel("• Active Downloads: 0") + self.active_downloads_label.setFont(QFont("Arial", 10)) + self.active_downloads_label.setStyleSheet("color: rgba(255, 255, 255, 0.8);") - queue_length = QLabel("• Queue Length: 0") - queue_length.setFont(QFont("Arial", 10)) - queue_length.setStyleSheet("color: rgba(255, 255, 255, 0.8);") + self.finished_downloads_label = QLabel("• Finished Downloads: 0") + self.finished_downloads_label.setFont(QFont("Arial", 10)) + self.finished_downloads_label.setStyleSheet("color: rgba(255, 255, 255, 0.8);") - stats_layout.addWidget(active_downloads) - stats_layout.addWidget(queue_length) + stats_layout.addWidget(self.active_downloads_label) + stats_layout.addWidget(self.finished_downloads_label) layout.addWidget(stats_frame) # Control buttons @@ -1443,15 +1876,25 @@ class DownloadsPage(QWidget): controls_layout.addWidget(clear_btn) layout.addWidget(controls_frame) - # Download Queue Section - self.download_queue = DownloadQueue() + # Download Queue Section - Now with tabs for active and finished downloads + self.download_queue = TabbedDownloadManager() layout.addWidget(self.download_queue) + # Initialize stats display + self.update_download_manager_stats(0, 0) + # Add stretch to push everything to top layout.addStretch() return panel + def update_download_manager_stats(self, active_count, finished_count): + """Update the download manager statistics display""" + if hasattr(self, 'active_downloads_label'): + self.active_downloads_label.setText(f"• Active Downloads: {active_count}") + if hasattr(self, 'finished_downloads_label'): + self.finished_downloads_label.setText(f"• Finished Downloads: {finished_count}") + def create_compact_status_bar(self): """Create a minimal status bar""" status_bar = QFrame() @@ -1706,6 +2149,7 @@ class DownloadsPage(QWidget): for result in results_to_show: result_item = SearchResultItem(result) result_item.download_requested.connect(self.start_download) + result_item.stream_requested.connect(self.start_stream) result_item.expansion_requested.connect(self.handle_expansion_request) # Insert before the stretch insert_position = self.search_results_layout.count() - 1 @@ -1802,6 +2246,7 @@ class DownloadsPage(QWidget): result = self.search_results[i] result_item = SearchResultItem(result) result_item.download_requested.connect(self.start_download) + result_item.stream_requested.connect(self.start_stream) result_item.expansion_requested.connect(self.handle_expansion_request) # Insert before the stretch (which is always last) insert_position = self.search_results_layout.count() - 1 @@ -1868,13 +2313,19 @@ class DownloadsPage(QWidget): title = filename artist = search_result.username + # Generate a unique download ID for tracking and cancellation + import time + download_id = f"{search_result.username}_{search_result.filename}_{int(time.time())}" + # Add to download queue immediately as "downloading" download_item = self.download_queue.add_download_item( title=title, artist=artist, status="downloading", progress=0, - file_size=search_result.size + file_size=search_result.size, + download_id=download_id, + soulseek_client=self.soulseek_client ) # Create and start download thread @@ -1882,7 +2333,10 @@ class DownloadsPage(QWidget): download_thread.download_completed.connect(self.on_download_completed, Qt.ConnectionType.QueuedConnection) download_thread.download_failed.connect(self.on_download_failed, Qt.ConnectionType.QueuedConnection) download_thread.download_progress.connect(self.on_download_progress, Qt.ConnectionType.QueuedConnection) - download_thread.finished.connect(lambda: self.on_download_thread_finished(download_thread), Qt.ConnectionType.QueuedConnection) + download_thread.finished.connect( + functools.partial(self.on_download_thread_finished, download_thread), + Qt.ConnectionType.QueuedConnection + ) # Track the thread self.download_threads.append(download_thread) @@ -1895,6 +2349,85 @@ class DownloadsPage(QWidget): except Exception as e: print(f"Failed to start download: {str(e)}") + def start_stream(self, search_result): + """Start streaming a search result using StreamingThread""" + try: + print(f"Starting stream: {search_result.filename} from {search_result.username}") + + # Check if file is a valid audio type + audio_extensions = ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav'] + filename_lower = search_result.filename.lower() + + is_audio = any(filename_lower.endswith(ext) for ext in audio_extensions) + + if is_audio: + print(f"✓ Streaming audio file: {search_result.filename}") + print(f" Quality: {search_result.quality}") + print(f" Size: {search_result.size // (1024*1024)}MB") + print(f" User: {search_result.username}") + + # Create and start streaming thread + streaming_thread = StreamingThread(self.soulseek_client, search_result) + streaming_thread.streaming_started.connect(self.on_streaming_started, Qt.ConnectionType.QueuedConnection) + streaming_thread.streaming_finished.connect(self.on_streaming_finished, Qt.ConnectionType.QueuedConnection) + streaming_thread.streaming_failed.connect(self.on_streaming_failed, Qt.ConnectionType.QueuedConnection) + streaming_thread.finished.connect( + functools.partial(self.on_streaming_thread_finished, streaming_thread), + Qt.ConnectionType.QueuedConnection + ) + + # Track the streaming thread + if not hasattr(self, 'streaming_threads'): + self.streaming_threads = [] + self.streaming_threads.append(streaming_thread) + + # Start the streaming + streaming_thread.start() + + else: + print(f"✗ Cannot stream non-audio file: {search_result.filename}") + + except Exception as e: + print(f"Failed to start stream: {str(e)}") + + def on_streaming_started(self, message, search_result): + """Handle streaming start""" + print(f"Streaming started: {message}") + + def on_streaming_finished(self, message, search_result): + """Handle streaming completion""" + print(f"Streaming finished: {message}") + + def on_streaming_failed(self, error_msg, search_result): + """Handle streaming failure""" + print(f"Streaming failed: {error_msg}") + + def on_streaming_thread_finished(self, thread): + """Clean up when streaming thread finishes""" + try: + if hasattr(self, 'streaming_threads') and thread in self.streaming_threads: + self.streaming_threads.remove(thread) + + # Disconnect all signals to prevent stale connections + try: + thread.streaming_started.disconnect() + thread.streaming_finished.disconnect() + thread.streaming_failed.disconnect() + thread.finished.disconnect() + except Exception: + pass # Ignore if signals are already disconnected + + # Ensure thread is properly stopped before deletion + if thread.isRunning(): + thread.stop() + thread.wait(1000) # Wait up to 1 second + + # Use QTimer.singleShot for delayed cleanup + QTimer.singleShot(100, thread.deleteLater) + + except Exception as e: + print(f"Error cleaning up finished streaming thread: {e}") + def on_download_completed(self, message, download_item): """Handle successful download start""" print(f"Download success: {message}") @@ -1949,43 +2482,41 @@ class DownloadsPage(QWidget): self.download_queue.clear_completed_downloads() def update_download_status(self): - """Poll slskd API for download status updates (QTimer callback)""" + """Poll slskd API for download status updates (QTimer callback) - FIXED VERSION""" if not self.soulseek_client or not self.download_queue.download_items: return - # Create a thread to handle the async operation - from PyQt6.QtCore import QThread, pyqtSignal - - class StatusUpdateThread(QThread): - status_updated = pyqtSignal(list) - - def __init__(self, soulseek_client): - super().__init__() - self.soulseek_client = soulseek_client - - def run(self): - import asyncio - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - downloads = loop.run_until_complete(self.soulseek_client.get_all_downloads()) - self.status_updated.emit(downloads or []) - except Exception as e: - print(f"Error fetching download status: {e}") - self.status_updated.emit([]) - finally: - if 'loop' in locals(): - loop.close() - + # CRITICAL FIX: Use tracked thread instead of anonymous thread def handle_status_update(downloads): - """Handle the download status update in the main thread""" + """Handle the download status update in the main thread - IMPROVED MATCHING""" try: + print(f"[DEBUG] Processing {len(downloads)} downloads from API") + for download_item in self.download_queue.download_items: - # Find matching download by filename - filename = f"{download_item.artist} - {download_item.title}" + # IMPROVED: Try multiple matching strategies + queue_title = download_item.title.lower() + queue_artist = download_item.artist.lower() + print(f"[DEBUG] Looking for: '{queue_artist} - {queue_title}'") + + found_match = False for download in downloads: - if filename.lower() in download.filename.lower(): + api_filename = download.filename.lower() + print(f"[DEBUG] Checking against: '{download.filename}'") + + # Strategy 1: Check if title is in the filename + title_match = queue_title in api_filename + + # Strategy 2: Check if artist is in the filename + artist_match = queue_artist in api_filename + + # Strategy 3: Reverse check - see if any part of queue item is in API filename + combined_check = f"{queue_artist} - {queue_title}" in api_filename + + if title_match or artist_match or combined_check: + print(f"[DEBUG] ✓ MATCH FOUND: {download.filename}") + print(f"[DEBUG] Status: {download.state}, Progress: {download.progress}%") + # Update the UI item with real data download_item.update_status( status=download.state, @@ -1993,14 +2524,43 @@ class DownloadsPage(QWidget): download_speed=download.speed, file_path=download.filename ) + + # AUTO-MOVE: Check if download is finished and move to finished tab + finished_states = ["completed", "cancelled", "completed, succeeded", "completed, cancelled"] + if download.state.lower() in [state.lower() for state in finished_states]: + print(f"[DEBUG] Moving finished download to finished tab: {download_item.title}") + self.download_queue.move_to_finished(download_item) + + found_match = True break + + if not found_match: + print(f"[DEBUG] ✗ NO MATCH for: '{queue_artist} - {queue_title}'") + except Exception as e: print(f"Error updating download UI: {e}") + import traceback + traceback.print_exc() - # Start the status update thread - status_thread = StatusUpdateThread(self.soulseek_client) - status_thread.status_updated.connect(handle_status_update) - status_thread.finished.connect(status_thread.deleteLater) + def on_status_thread_finished(thread): + """Clean up status thread when finished""" + try: + if thread in self.status_update_threads: + self.status_update_threads.remove(thread) + thread.deleteLater() + except Exception as e: + print(f"Error cleaning up status thread: {e}") + + # CRITICAL FIX: Create tracked status update thread + status_thread = TrackedStatusUpdateThread(self.soulseek_client, self) + status_thread.status_updated.connect(handle_status_update, Qt.ConnectionType.QueuedConnection) + status_thread.finished.connect( + functools.partial(on_status_thread_finished, status_thread), + Qt.ConnectionType.QueuedConnection + ) + + # CRITICAL FIX: Track the thread for proper cleanup + self.status_update_threads.append(status_thread) status_thread.start() @@ -2041,6 +2601,28 @@ class DownloadsPage(QWidget): self.session_thread.deleteLater() self.session_thread = None + # CRITICAL FIX: Stop all status update threads + for status_thread in self.status_update_threads[:]: # Copy list to avoid modification during iteration + try: + # Disconnect signals first + try: + status_thread.status_updated.disconnect() + status_thread.finished.disconnect() + except Exception: + pass # Ignore if signals are already disconnected + + if status_thread.isRunning(): + status_thread.stop() + status_thread.wait(2000) # Wait up to 2 seconds + if status_thread.isRunning(): + status_thread.terminate() + status_thread.wait(1000) + status_thread.deleteLater() + except Exception as e: + print(f"Error cleaning up status update thread: {e}") + + self.status_update_threads.clear() + # Stop all download threads with proper cleanup for download_thread in self.download_threads[:]: # Copy list to avoid modification during iteration try: