diff --git a/CANCEL_BUTTON_IMPLEMENTATION.md b/CANCEL_BUTTON_IMPLEMENTATION.md new file mode 100644 index 00000000..3c3b454c --- /dev/null +++ b/CANCEL_BUTTON_IMPLEMENTATION.md @@ -0,0 +1,512 @@ +# 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. \ No newline at end of file diff --git a/services/__pycache__/sync_service.cpython-312.pyc b/services/__pycache__/sync_service.cpython-312.pyc index eb835685..02cedd6e 100644 Binary files a/services/__pycache__/sync_service.cpython-312.pyc and b/services/__pycache__/sync_service.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/artists.cpython-310.pyc b/ui/pages/__pycache__/artists.cpython-310.pyc index af7f2152..b3fbb803 100644 Binary files a/ui/pages/__pycache__/artists.cpython-310.pyc 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 ad4b43a0..c1dd6529 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 0c94225c..5733fff6 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__/dashboard.cpython-312.pyc b/ui/pages/__pycache__/dashboard.cpython-312.pyc index d87e17ed..7636d622 100644 Binary files a/ui/pages/__pycache__/dashboard.cpython-312.pyc and b/ui/pages/__pycache__/dashboard.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/downloads.cpython-312.pyc b/ui/pages/__pycache__/downloads.cpython-312.pyc index 6b70f727..a1efd24c 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__/settings.cpython-312.pyc b/ui/pages/__pycache__/settings.cpython-312.pyc index 2afcc590..796d8635 100644 Binary files a/ui/pages/__pycache__/settings.cpython-312.pyc and b/ui/pages/__pycache__/settings.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/sync.cpython-310.pyc b/ui/pages/__pycache__/sync.cpython-310.pyc index 59a7e8ea..3b93e7d9 100644 Binary files a/ui/pages/__pycache__/sync.cpython-310.pyc and b/ui/pages/__pycache__/sync.cpython-310.pyc differ diff --git a/ui/pages/artists.py b/ui/pages/artists.py index 56b7bb12..2d76e35e 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -1788,6 +1788,7 @@ class DownloadMissingAlbumTracksModal(QDialog): self.download_in_progress = False self.cancel_requested = False self.permanently_failed_tracks = [] + self.cancelled_tracks = set() # Track indices of cancelled tracks print(f"📊 Total album tracks: {self.total_tracks}") @@ -2062,13 +2063,15 @@ class DownloadMissingAlbumTracksModal(QDialog): header_label.setStyleSheet("color: #ffffff; padding: 5px;") self.track_table = QTableWidget() - self.track_table.setColumnCount(5) - self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Duration", "Matched", "Status"]) + 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.setStyleSheet(""" QTableWidget { @@ -2086,7 +2089,7 @@ class DownloadMissingAlbumTracksModal(QDialog): self.track_table.setAlternatingRowColors(True) self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.track_table.verticalHeader().setDefaultSectionSize(35) + self.track_table.verticalHeader().setDefaultSectionSize(50) self.track_table.verticalHeader().setVisible(False) self.populate_track_table() @@ -2129,6 +2132,16 @@ class DownloadMissingAlbumTracksModal(QDialog): status_item = QTableWidgetItem("—") status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.track_table.setItem(i, 4, status_item) + + # 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) + for col in range(5): self.track_table.item(i, col).setFlags(self.track_table.item(i, col).flags() & ~Qt.ItemFlag.ItemIsEditable) @@ -2158,6 +2171,107 @@ class DownloadMissingAlbumTracksModal(QDialog): """Convert milliseconds to MM:SS format""" seconds = duration_ms // 1000 return f"{seconds // 60}:{seconds % 60:02d}" + + 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) + + 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}") + + def cancel_track(self, row): + """Cancel a specific track - works at any phase""" + # Get cancel button and disable it + 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.setEnabled(False) + cancel_button.setText("✓") + + # 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.album.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) def create_buttons(self): """Create improved button section""" @@ -2247,6 +2361,7 @@ class DownloadMissingAlbumTracksModal(QDialog): 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 @@ -2255,7 +2370,9 @@ class DownloadMissingAlbumTracksModal(QDialog): 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)) + # 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)) def on_analysis_completed(self, results): """Handle analysis completion""" @@ -2305,6 +2422,14 @@ class DownloadMissingAlbumTracksModal(QDialog): track_result = self.missing_tracks[self.download_queue_index] track = track_result.spotify_track track_index = self.find_track_index_in_album(track) + + # 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 + self.track_table.setItem(track_index, 4, QTableWidgetItem("🔍 Searching...")) self.search_and_download_track_parallel(track, self.download_queue_index, track_index) self.active_parallel_downloads += 1 @@ -2625,21 +2750,32 @@ class DownloadMissingAlbumTracksModal(QDialog): def on_parallel_track_completed(self, download_index, success): """Handle completion of a parallel track download""" + if not hasattr(self, 'parallel_search_tracking'): + print(f"⚠️ parallel_search_tracking not initialized yet, skipping completion for download {download_index}") + return track_info = self.parallel_search_tracking.get(download_index) if not track_info or track_info.get('completed', False): return track_info['completed'] = True if success: 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 else: - self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("❌ Failed")) + # 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: + 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 - if track_info not in self.permanently_failed_tracks: - self.permanently_failed_tracks.append(track_info) - self.update_failed_matches_button() self.completed_downloads += 1 self.active_parallel_downloads -= 1 @@ -2687,6 +2823,44 @@ class DownloadMissingAlbumTracksModal(QDialog): album_name = getattr(self.album, 'name', 'Unknown Album') self.parent_artists_page.scan_manager.request_scan(f"Album download completed: {album_name} ({self.successful_downloads} tracks)") + # 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.album.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") + # Add permanently failed tracks to wishlist before showing completion message failed_count = len(self.permanently_failed_tracks) wishlist_added_count = 0 diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 562242db..9f9d45c8 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -3815,6 +3815,7 @@ class DownloadMissingTracksModal(QDialog): self.cancel_requested = False self.permanently_failed_tracks = [] + self.cancelled_tracks = set() # Track indices of cancelled tracks print(f"📊 Total tracks: {self.total_tracks}") @@ -4102,13 +4103,15 @@ class DownloadMissingTracksModal(QDialog): header_label.setStyleSheet("color: #ffffff; padding: 5px;") self.track_table = QTableWidget() - self.track_table.setColumnCount(5) - self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Duration", "Matched", "Status"]) + 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.setStyleSheet(""" QTableWidget { @@ -4126,7 +4129,7 @@ class DownloadMissingTracksModal(QDialog): self.track_table.setAlternatingRowColors(True) self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.track_table.verticalHeader().setDefaultSectionSize(35) + self.track_table.verticalHeader().setDefaultSectionSize(50) self.track_table.verticalHeader().setVisible(False) self.populate_track_table() @@ -4153,6 +4156,16 @@ class DownloadMissingTracksModal(QDialog): status_item = QTableWidgetItem("—") status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.track_table.setItem(i, 4, status_item) + + # 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) + for col in range(5): self.track_table.item(i, col).setFlags(self.track_table.item(i, col).flags() & ~Qt.ItemFlag.ItemIsEditable) @@ -4160,6 +4173,107 @@ class DownloadMissingTracksModal(QDialog): """Convert milliseconds to MM:SS format""" seconds = duration_ms // 1000 return f"{seconds // 60}:{seconds % 60:02d}" + + 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) + + 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}") + + def cancel_track(self, row): + """Cancel a specific track - works at any phase""" + # Get cancel button and disable it + 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.setEnabled(False) + cancel_button.setText("✓") + + # 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) def create_buttons(self): """Create improved button section""" @@ -4247,6 +4361,7 @@ class DownloadMissingTracksModal(QDialog): 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 @@ -4255,7 +4370,9 @@ class DownloadMissingTracksModal(QDialog): 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)) + # 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)) def on_analysis_completed(self, results): """Handle analysis completion""" @@ -4304,6 +4421,14 @@ class DownloadMissingTracksModal(QDialog): track_result = self.missing_tracks[self.download_queue_index] track = track_result.spotify_track track_index = self.find_track_index_in_playlist(track) + + # 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 + self.track_table.setItem(track_index, 4, QTableWidgetItem("🔍 Searching...")) self.search_and_download_track_parallel(track, self.download_queue_index, track_index) self.active_parallel_downloads += 1 @@ -4617,6 +4742,9 @@ class DownloadMissingTracksModal(QDialog): def on_parallel_track_completed(self, download_index, success): """Handle completion of a parallel track download""" + if not hasattr(self, 'parallel_search_tracking'): + print(f"⚠️ parallel_search_tracking not initialized yet, skipping completion for download {download_index}") + return track_info = self.parallel_search_tracking.get(download_index) if not track_info or track_info.get('completed', False): return @@ -4624,18 +4752,26 @@ class DownloadMissingTracksModal(QDialog): 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 # --- FIX --- # Corrected the label update to use the incremented counter variable. self.downloaded_count_label.setText(str(self.downloaded_tracks_count)) self.successful_downloads += 1 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")) + # 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 - if track_info not in self.permanently_failed_tracks: - self.permanently_failed_tracks.append(track_info) - self.update_failed_matches_button() self.completed_downloads += 1 self.active_parallel_downloads -= 1 @@ -4706,6 +4842,44 @@ class DownloadMissingTracksModal(QDialog): if self.successful_downloads > 0 and hasattr(self, 'parent_sync_page') and self.parent_sync_page.scan_manager: self.parent_sync_page.scan_manager.request_scan(f"Playlist download completed ({self.successful_downloads} tracks)") + # 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") + # Add permanently failed tracks to wishlist before showing completion message failed_count = len(self.permanently_failed_tracks) wishlist_added_count = 0