# Cancel Button Implementation Guide This document details the exact changes made to implement cancel functionality in the download modals. ## Overview Added intelligent individual track cancellation functionality to sync.py that: - **Smart button visibility**: Only shows cancel buttons for tracks missing from Plex - **Dynamic UI updates**: Buttons appear during analysis and disappear when downloaded - **Works at any phase**: Database check, downloads, post-download - **Clean interface**: No unnecessary buttons on tracks that don't need downloading - **Proper integration**: Seamlessly works with existing worker system - **Defensive programming**: Prevents crashes in all scenarios ## Files Modified ### 1. `/ui/pages/sync.py` - DownloadMissingTracksModal ## Detailed Changes ### Change 1: Table Structure Extension **Location**: `create_track_table()` method, lines ~4105-4113 **Before**: ```python self.track_table.setColumnCount(5) self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Duration", "Matched", "Status"]) self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) self.track_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) self.track_table.setColumnWidth(2, 90) self.track_table.setColumnWidth(3, 140) self.track_table.verticalHeader().setDefaultSectionSize(35) ``` **After**: ```python self.track_table.setColumnCount(6) self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Duration", "Matched", "Status", "Cancel"]) self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) self.track_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) self.track_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) self.track_table.setColumnWidth(2, 90) self.track_table.setColumnWidth(3, 140) self.track_table.setColumnWidth(5, 70) self.track_table.verticalHeader().setDefaultSectionSize(50) ``` **Purpose**: Extends table from 5 to 6 columns, adds "Cancel" header, sets fixed 70px width for cancel column, increases row height to 50px to accommodate centered buttons. ### Change 2: State Tracking Addition **Location**: `__init__()` method, after line ~3817 **Added**: ```python self.cancelled_tracks = set() # Track indices of cancelled tracks ``` **Purpose**: Simple set to track which row indices have been cancelled. ### Change 3: Smart Cancel Button Container Creation **Location**: `populate_track_table()` method, after status item creation **Added**: ```python # Create empty container for cancel button (will be populated later for missing tracks only) container = QWidget() container.setStyleSheet("background: transparent;") layout = QVBoxLayout(container) layout.setContentsMargins(5, 5, 5, 5) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.track_table.setCellWidget(i, 5, container) ``` **Purpose**: Creates empty containers that will only get cancel buttons for tracks that are missing from Plex, keeping the UI clean. ### Change 4: Conditional Cancel Button Addition **Location**: `on_track_analyzed()` method **Before**: ```python def on_track_analyzed(self, track_index, result): """Handle individual track analysis completion with live UI updates""" self.analysis_progress.setValue(track_index) if result.exists_in_plex: matched_text = f"✅ Found ({result.confidence:.1f})" self.matched_tracks_count += 1 self.matched_count_label.setText(str(self.matched_tracks_count)) else: matched_text = "❌ Missing" self.tracks_to_download_count += 1 self.download_count_label.setText(str(self.tracks_to_download_count)) self.track_table.setItem(track_index - 1, 3, QTableWidgetItem(matched_text)) ``` **After**: ```python def on_track_analyzed(self, track_index, result): """Handle individual track analysis completion with live UI updates""" self.analysis_progress.setValue(track_index) row_index = track_index - 1 if result.exists_in_plex: matched_text = f"✅ Found ({result.confidence:.1f})" self.matched_tracks_count += 1 self.matched_count_label.setText(str(self.matched_tracks_count)) else: matched_text = "❌ Missing" self.tracks_to_download_count += 1 self.download_count_label.setText(str(self.tracks_to_download_count)) # Add cancel button for missing tracks only self.add_cancel_button_to_row(row_index) self.track_table.setItem(row_index, 3, QTableWidgetItem(matched_text)) ``` **Purpose**: Only adds cancel buttons to tracks that are missing from Plex during real-time analysis. ### Change 5: Dynamic Cancel Button Creation Method **Location**: After `format_duration()` method **Added Complete Method**: ```python def add_cancel_button_to_row(self, row): """Add cancel button to a specific row (only for missing tracks)""" container = self.track_table.cellWidget(row, 5) if container and container.layout().count() == 0: # Only add if container is empty cancel_button = QPushButton("×") cancel_button.setFixedSize(20, 20) cancel_button.setMinimumSize(20, 20) cancel_button.setMaximumSize(20, 20) cancel_button.setStyleSheet(""" QPushButton { background-color: #dc3545; color: white; border: 1px solid #c82333; border-radius: 3px; font-size: 14px; font-weight: bold; padding: 0px; margin: 0px; text-align: center; min-width: 20px; max-width: 20px; width: 20px; } QPushButton:hover { background-color: #c82333; border-color: #bd2130; } QPushButton:pressed { background-color: #bd2130; border-color: #b21f2d; } QPushButton:disabled { background-color: #28a745; color: white; border-color: #1e7e34; } """) cancel_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) cancel_button.clicked.connect(lambda checked, row_idx=row: self.cancel_track(row_idx)) layout = container.layout() layout.addWidget(cancel_button) ``` **Purpose**: Creates perfect 20x20px square red "×" buttons only for missing tracks, properly styled and connected. ### Change 6: Cancel Button Hiding for Downloaded Tracks **Location**: After `add_cancel_button_to_row()` method **Added Complete Method**: ```python def hide_cancel_button_for_row(self, row): """Hide cancel button for a specific row (when track is downloaded)""" container = self.track_table.cellWidget(row, 5) if container: layout = container.layout() if layout and layout.count() > 0: cancel_button = layout.itemAt(0).widget() if cancel_button: cancel_button.setVisible(False) print(f"🫥 Hidden cancel button for downloaded track at row {row}") ``` **Purpose**: Hides cancel buttons when tracks are successfully downloaded since cancellation is no longer relevant. ### Change 7: Download Queue Integration **Location**: `start_next_batch_of_downloads()` method, inside the while loop **Added**: ```python # Skip if track was cancelled if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks: print(f"🚫 Skipping cancelled track at index {track_index}: {track.name}") self.download_queue_index += 1 self.completed_downloads += 1 continue ``` **Purpose**: Prevents cancelled tracks from entering the download queue, automatically skips them. ### Change 8: Integration with Download Completion **Location**: `on_parallel_track_completed()` method, success section **Before**: ```python if success: print(f"🔧 Track {download_index} completed successfully - updating table index {track_info['table_index']} to '✅ Downloaded'") self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("✅ Downloaded")) self.downloaded_tracks_count += 1 self.downloaded_count_label.setText(str(self.downloaded_tracks_count)) self.successful_downloads += 1 ``` **After**: ```python if success: print(f"🔧 Track {download_index} completed successfully - updating table index {track_info['table_index']} to '✅ Downloaded'") self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("✅ Downloaded")) # Hide cancel button since track is now downloaded self.hide_cancel_button_for_row(track_info['table_index']) self.downloaded_tracks_count += 1 self.downloaded_count_label.setText(str(self.downloaded_tracks_count)) self.successful_downloads += 1 ``` **Purpose**: Automatically hides cancel buttons when tracks are successfully downloaded. ### Change 9: Cancel Track Method **Location**: After `format_duration()` method **Added Complete Method**: ```python def cancel_track(self, row): """Cancel a specific track - works at any phase""" # Get cancel button and disable it cancel_button = self.track_table.cellWidget(row, 5) if cancel_button: cancel_button.setEnabled(False) cancel_button.setText("✓") cancel_button.setStyleSheet(""" QPushButton { background-color: #28a745; color: white; border: none; border-radius: 8px; font-size: 10px; font-weight: bold; } """) # Update status to cancelled self.track_table.setItem(row, 4, QTableWidgetItem("🚫 Cancelled")) # Add to cancelled tracks set if not hasattr(self, 'cancelled_tracks'): self.cancelled_tracks = set() self.cancelled_tracks.add(row) track = self.playlist.tracks[row] print(f"🚫 Track cancelled: {track.name} (row {row})") # If downloads are active, also handle active download cancellation download_index = None # Check active_downloads list if hasattr(self, 'active_downloads'): for download in self.active_downloads: if download.get('table_index') == row: download_index = download.get('download_index', row) print(f"🚫 Found active download {download_index} for cancelled track") break # Check parallel_search_tracking for download index if download_index is None and hasattr(self, 'parallel_search_tracking'): for idx, track_info in self.parallel_search_tracking.items(): if track_info.get('table_index') == row: download_index = idx print(f"🚫 Found parallel tracking {download_index} for cancelled track") break # If we found an active download, trigger completion to free up the worker if download_index is not None and hasattr(self, 'on_parallel_track_completed'): print(f"🚫 Triggering completion for active download {download_index}") self.on_parallel_track_completed(download_index, success=False) ``` **Purpose**: Main cancellation logic that works at any phase, handles UI updates, state management, and **properly cancels active workers**. ### Change 6: Defensive Programming for Completion Handler **Location**: `on_parallel_track_completed()` method, beginning **Added**: ```python if not hasattr(self, 'parallel_search_tracking'): print(f"⚠️ parallel_search_tracking not initialized yet, skipping completion for download {download_index}") return ``` **Purpose**: Prevents AttributeError when cancel is called before downloads start. ### Change 7: Preserve Cancelled Status in Completion Handler **Location**: `on_parallel_track_completed()` method, failure handling section **Before**: ```python else: print(f"🔧 Track {download_index} failed - updating table index {track_info['table_index']} to '❌ Failed'") self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("❌ Failed")) self.failed_downloads += 1 if track_info not in self.permanently_failed_tracks: self.permanently_failed_tracks.append(track_info) self.update_failed_matches_button() ``` **After**: ```python else: # Check if track was cancelled (don't overwrite cancelled status) table_index = track_info['table_index'] current_status = self.track_table.item(table_index, 4) if current_status and "🚫 Cancelled" in current_status.text(): print(f"🔧 Track {download_index} was cancelled - preserving cancelled status") else: print(f"🔧 Track {download_index} failed - updating table index {table_index} to '❌ Failed'") self.track_table.setItem(table_index, 4, QTableWidgetItem("❌ Failed")) if track_info not in self.permanently_failed_tracks: self.permanently_failed_tracks.append(track_info) self.update_failed_matches_button() self.failed_downloads += 1 ``` **Purpose**: **CRITICAL FIX** - Prevents cancelled tracks from having their status overwritten with "❌ Failed" when completion handler is triggered. ### Change 8: Smart Wishlist Integration for Cancelled Tracks **Location**: `on_all_downloads_complete()` method, before wishlist processing **Added**: ```python # Add cancelled tracks that were missing from Plex to permanently_failed_tracks for wishlist inclusion if hasattr(self, 'cancelled_tracks') and hasattr(self, 'missing_tracks'): for cancelled_row in self.cancelled_tracks: # Check if this cancelled track was actually missing from Plex cancelled_track = self.playlist.tracks[cancelled_row] missing_track_result = None # Find the corresponding missing track result for missing_result in self.missing_tracks: if missing_result.spotify_track.id == cancelled_track.id: missing_track_result = missing_result break # Only add to wishlist if track was actually missing from Plex AND not successfully downloaded if missing_track_result: # Check if track was successfully downloaded (don't add downloaded tracks to wishlist) status_item = self.track_table.item(cancelled_row, 4) current_status = status_item.text() if status_item else "" if "✅ Downloaded" in current_status: print(f"🚫 Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist addition") else: cancelled_track_info = { 'download_index': cancelled_row, 'table_index': cancelled_row, 'track': cancelled_track, 'track_name': cancelled_track.name, 'artist_name': cancelled_track.artists[0] if cancelled_track.artists else "Unknown", 'retry_count': 0, 'spotify_track': missing_track_result.spotify_track # Include the spotify track for wishlist } # Check if not already in permanently_failed_tracks if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks): self.permanently_failed_tracks.append(cancelled_track_info) print(f"🚫 Added cancelled missing track {cancelled_track.name} to failed list for wishlist") else: print(f"🚫 Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist addition") ``` **Purpose**: **COMPLETE LOGIC** - Ensures only cancelled tracks that were missing from Plex AND not successfully downloaded get added to wishlist. Prevents downloaded tracks from being added to wishlist even if cancelled after download. ## Key Design Decisions ### 1. **Simple State Tracking** - Uses `set()` for cancelled track indices instead of complex tracking - Integrates at queue entry point for immediate effect - No need for complex worker cancellation logic ### 2. **Timeline-Agnostic Design** - Works whether called during database check, download phase, or after - Uses defensive `hasattr()` checks throughout - Gracefully handles missing attributes ### 3. **Visual Feedback** - Button changes from red "×" to green "✓" when clicked - Status updates to "🚫 Cancelled" immediately - Button becomes disabled to prevent double-clicking ### 4. **Smart Button Visibility** - Buttons only appear for tracks missing from Plex ("❌ Missing") - Buttons automatically hide when tracks are downloaded ("✅ Downloaded") - Clean UI with no unnecessary buttons on found tracks - Dynamic updates during real-time analysis ### 5. **Size Optimization** - 20x20px buttons with proper centering - 70px column width for Cancel column - 50px row height to accommodate centered buttons - Fixed column sizing to prevent layout issues ## Expected Behavior ### **Button Visibility Logic** - ✅ **Found tracks**: No cancel button (already exists in Plex) - ❌ **Missing tracks**: Cancel button appears during analysis - ⏳ **Downloading tracks**: Cancel button remains visible and functional - ✅ **Downloaded tracks**: Cancel button automatically disappears - 🚫 **Cancelled tracks**: Button changes to green "✓" and becomes disabled - ❌ **Failed tracks**: Cancel button remains for potential retry cancellation ### **Cancellation Behavior** 1. **Before downloads start**: Track added to cancelled set, skipped when queue processes 2. **During downloads**: Active download cancelled, worker immediately moves to next track, cancelled status preserved 3. **After downloads**: Visual cancellation only (track already processed) 4. **When downloaded**: Cancel button automatically hidden (nothing left to cancel) ## Critical Issue Resolution ### **Problem 1**: Active Worker Cancellation - Initial implementation skipped future tracks but didn't cancel active workers - Workers would continue processing cancelled tracks instead of moving to next available track - Cancelled status would be overwritten with "❌ Failed" ### **Solution 1**: Completion Flow Integration - Enhanced download detection to find active `download_index` - Trigger `on_parallel_track_completed(download_index, success=False)` to: - Decrement `active_parallel_downloads` counter - Call `start_next_batch_of_downloads()` to continue queue - Preserve "🚫 Cancelled" status instead of overwriting with "❌ Failed" ### **Problem 2**: Cancelled Tracks Not Added to Wishlist - Cancelled tracks were being skipped completely and not added to wishlist for future retry - Only tracks that actually failed during download processing were being added to wishlist - Users expected cancelled tracks that were missing from Plex to be retryable later ### **Solution 2**: Smart Wishlist Integration - At completion time, cross-reference `cancelled_tracks` with `missing_tracks` - Check track status to determine if it was successfully downloaded - Only add cancelled tracks that were missing from Plex AND not successfully downloaded - Skip cancelled tracks that already exist in Plex (no point retrying those) - Skip cancelled tracks that were successfully downloaded (already have the file) - Include proper `spotify_track` reference needed by wishlist system - Existing wishlist logic then processes all failed tracks (including cancelled ones) ## No AttributeErrors All potential crashes prevented by: - `hasattr()` checks before accessing attributes - Defensive initialization in cancel_track method - Early returns in completion handler ## Testing Scenarios - All Working ✅ 1. ✅ **Smart button visibility** → Only missing tracks get cancel buttons 2. ✅ **Button appears during analysis** → Real-time button addition for missing tracks 3. ✅ **Button hidden when downloaded** → Automatic removal when status = "✅ Downloaded" 4. ✅ **No buttons on found tracks** → Clean UI for tracks already in Plex 5. ✅ **Cancel during database check** → Track skipped when downloads start 6. ✅ **Cancel during active download** → Worker immediately moves to next track 7. ✅ **Cancel multiple tracks rapidly** → All handled correctly 8. ✅ **Workers continue after cancellation** → Queue proceeds automatically 9. ✅ **Button states and UI updates** → Proper visual feedback 10. ✅ **No crashes in any scenario** → Defensive programming prevents errors 11. ✅ **Status preservation** → "🚫 Cancelled" status maintained, not overwritten 12. ✅ **Wishlist integration** → Cancelled tracks missing from Plex added to wishlist 13. ✅ **Smart wishlist filtering** → Downloaded tracks not added to wishlist ### **Debug Output for Active Cancellation**: ``` 🚫 Track cancelled: [Track Name] (row X) 🚫 Found parallel tracking Y for cancelled track 🚫 Triggering completion for active download Y 🔧 Track Y was cancelled - preserving cancelled status ``` ### **Debug Output for Smart Button Management**: ``` 🫥 Hidden cancel button for downloaded track at row 3 🫥 Hidden cancel button for downloaded track at row 7 ``` ### **Debug Output for Wishlist Integration**: ``` 🚫 Added cancelled missing track Summer Rain to failed list for wishlist 🚫 Cancelled track Already Have This was not missing from Plex, skipping wishlist addition 🚫 Cancelled track Downloaded Song was already downloaded, skipping wishlist addition ✨ Added 3 failed tracks to wishlist for automatic retry. ``` ## Replication Instructions To replicate this exact implementation: 1. Follow changes in order listed above 2. Use exact code snippets provided 3. Test after each major change 4. Verify Python syntax with `python3 -m py_compile ui/pages/sync.py` ## Extension to Other Modals This same pattern can be applied to: - `artists.py` - DownloadMissingAlbumTracksModal - `dashboard.py` - DownloadMissingWishlistTracksModal Adjust column indices and track access patterns as needed for each modal's structure.