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

22 KiB
Raw Blame History

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:

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:

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:

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:

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

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:

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:

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:

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:

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

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:

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:

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:

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:

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:

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:

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