soulsync/CANCEL_BUTTON_IMPLEMENTATION.md
2025-08-12 14:46:59 -07:00

512 lines
No EOL
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.