This commit is contained in:
Broque Thomas 2025-07-21 14:58:40 -07:00
parent cd45ba0b84
commit 86306b7dda
3 changed files with 268 additions and 126 deletions

View file

@ -212,8 +212,8 @@ Playlist Track → Plex Check → (Missing) → Soulseek Search → Quality Filt
- ✅ Three-button system: Begin Search, Cancel, Close
3. **✅ COMPLETED - Modal State Persistence**
- ✅ Progress bubble system when modal is closed during operations
- ✅ Clickable bubble to reopen modal and review progress
- ✅ Playlist status indicator system when modal is closed during operations
- ✅ Real-time status updates on playlist buttons (🔍 Analyzing, ⏬ Downloading)
- ✅ Maintain operation state across modal open/close cycles
4. **✅ COMPLETED - Soulseek Search Integration**
@ -236,26 +236,43 @@ Playlist Track → Plex Check → (Missing) → Soulseek Search → Quality Filt
- Provide real-time progress updates
- Implement retry logic for API failures
### ✅ COMPLETE WORKFLOW IMPLEMENTED:
**User Experience Flow:**
1. **Click "Download Missing Tracks"** → Sync modal closes, Download modal opens
2. **Click "Begin Search"** → Playlist button shows "🔍 Analyzing..." status
3. **Plex Analysis Phase** → Real-time track table updates with ✅/❌ status
4. **Auto-Download Phase** → Playlist button shows "⏬ Downloading X/Y" status
5. **Modal Interaction Options:**
- **Cancel Button**: Stops all operations, closes modal, restores playlist button
- **Close Button**: Closes modal, continues operations with status updates
- **Re-open Modal**: Click playlist status indicator to view detailed progress
6. **Completion** → Playlist button returns to normal "Sync / Download"
**Playlist Status Indicators:**
- `🔍 Analyzing X/Y` - During Plex analysis phase
- `⏬ Downloading X/Y` - During Soulseek download phase
- `✅ Complete` - When all operations finished
- Clickable to reopen detailed progress modal
**Track Table Status Updates:**
- **Matched Column**: ✅ Found (confidence), ❌ Missing, ⏳ Pending
- **Downloaded Column**: ✅ Downloaded, ⏬ Downloading, ❌ Failed, ⏳ Pending
### 🔧 TECHNICAL ARCHITECTURE:
**Minimal Changes Approach:**
- Extend existing downloads.py with custom path parameter
- Reuse matched download folder organization logic
- Leverage existing Plex integration and search functionality
- Build on current progress tracking and queue management
**Core Integration Points:**
- `SyncPage` - Enhanced with soulseek_client parameter and playlist status indicators
- `PlaylistItem` - Added show/hide/update operation status methods
- `DownloadMissingTracksModal` - Complete workflow with real-time UI updates
- `TrackDownloadWorker` - Background Soulseek download integration
- Existing `plex_client.py` and `soulseek_client.py` - Leveraged without modification
**Data Flow:**
```
Playlist → Spotify Tracks → Plex Check → Missing Tracks → Soulseek Search → Download Queue → Folder Organization
Playlist → Spotify Tracks → Plex Analysis → Track Table Updates → Missing Tracks → Soulseek Downloads → Status Updates
```
**Integration Points:**
- `downloads.py` - custom path support for playlist downloads
- `plex_client.py` - track existence checking with confidence scoring
- `soulseek_client.py` - individual track search and download
- `spotify_client.py` - playlist track metadata retrieval
- `sync.py` - button handling and progress UI
### 🎯 SUCCESS METRICS:
- No breaking changes to existing download functionality
- Seamless integration with current UI and workflow

View file

@ -99,7 +99,7 @@ class MainWindow(QMainWindow):
# Create and add pages
self.dashboard_page = DashboardPage()
self.sync_page = SyncPage(self.spotify_client, self.plex_client)
self.sync_page = SyncPage(self.spotify_client, self.plex_client, self.soulseek_client)
self.downloads_page = DownloadsPage(self.soulseek_client)
self.artists_page = ArtistsPage()
self.settings_page = SettingsPage()

View file

@ -693,7 +693,13 @@ class PlaylistDetailsModal(QDialog):
"Plex client not available. Please check your configuration.")
return
print("✅ Plex client available")
if not hasattr(self.parent_page, 'soulseek_client') or not self.parent_page.soulseek_client:
print("❌ Soulseek client not available")
QMessageBox.warning(self, "Service Unavailable",
"Soulseek client not available. Please check your configuration.")
return
print("✅ Plex and Soulseek clients available")
# Create and show the enhanced download missing tracks modal
try:
@ -704,6 +710,11 @@ class PlaylistDetailsModal(QDialog):
# Store modal reference to prevent garbage collection
self.download_modal = modal
# Find and store modal reference in playlist item for reopening
playlist_item = self.find_playlist_item_from_sync_modal()
if playlist_item:
playlist_item.set_download_modal(modal)
print("🖥️ Closing current sync modal...")
self.accept() # Close the current sync modal
@ -717,6 +728,20 @@ class PlaylistDetailsModal(QDialog):
traceback.print_exc()
QMessageBox.critical(self, "Modal Error", f"Failed to open download modal: {str(e)}")
def find_playlist_item_from_sync_modal(self):
"""Find the PlaylistItem widget for this playlist from sync modal"""
if not hasattr(self.parent_page, 'current_playlists'):
return None
# Look through the parent page's playlist items
for i in range(self.parent_page.playlist_layout.count()):
item = self.parent_page.playlist_layout.itemAt(i)
if item and item.widget() and isinstance(item.widget(), PlaylistItem):
playlist_item = item.widget()
if playlist_item.playlist and playlist_item.playlist.id == self.playlist.id:
return playlist_item
return None
def start_playlist_missing_tracks_download(self):
"""Start the process of downloading missing tracks from playlist"""
track_count = len(self.playlist.tracks)
@ -983,11 +1008,11 @@ class PlaylistItem(QFrame):
content_layout.addWidget(name_label)
content_layout.addLayout(info_layout)
# Action button
action_btn = QPushButton("Sync / Download")
action_btn.setFixedSize(120, 30) # Slightly wider for longer text
action_btn.clicked.connect(self.on_view_details_clicked)
action_btn.setStyleSheet("""
# Action button or status indicator
self.action_btn = QPushButton("Sync / Download")
self.action_btn.setFixedSize(120, 30) # Slightly wider for longer text
self.action_btn.clicked.connect(self.on_view_details_clicked)
self.action_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid #1db954;
@ -1002,10 +1027,36 @@ class PlaylistItem(QFrame):
}
""")
# Status label (hidden by default)
self.status_label = QPushButton()
self.status_label.setFixedSize(120, 30)
self.status_label.setStyleSheet("""
QPushButton {
background: #1db954;
border: 1px solid #169441;
border-radius: 15px;
color: #000000;
font-size: 10px;
font-weight: bold;
padding: 5px;
text-align: center;
}
QPushButton:hover {
background: #1ed760;
cursor: pointer;
}
""")
self.status_label.clicked.connect(self.on_status_clicked)
self.status_label.hide()
# Store reference to the download modal
self.download_modal = None
layout.addWidget(self.checkbox)
layout.addLayout(content_layout)
layout.addStretch()
layout.addWidget(action_btn)
layout.addWidget(self.action_btn)
layout.addWidget(self.status_label)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
@ -1016,6 +1067,32 @@ class PlaylistItem(QFrame):
"""Handle View Details button click"""
if self.playlist:
self.view_details_clicked.emit(self.playlist)
def show_operation_status(self, status_text):
"""Show operation status and hide action button"""
self.status_label.setText(status_text)
self.status_label.show()
self.action_btn.hide()
def hide_operation_status(self):
"""Hide operation status and show action button"""
self.status_label.hide()
self.action_btn.show()
def update_operation_status(self, status_text):
"""Update the operation status text"""
self.status_label.setText(status_text)
def set_download_modal(self, modal):
"""Store reference to the download modal"""
self.download_modal = modal
def on_status_clicked(self):
"""Handle status button click - reopen modal"""
if self.download_modal and not self.download_modal.isVisible():
self.download_modal.show()
self.download_modal.activateWindow()
self.download_modal.raise_()
class SyncOptionsPanel(QFrame):
def __init__(self, parent=None):
@ -1096,10 +1173,11 @@ class SyncOptionsPanel(QFrame):
layout.addLayout(quality_layout)
class SyncPage(QWidget):
def __init__(self, spotify_client=None, plex_client=None, parent=None):
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, parent=None):
super().__init__(parent)
self.spotify_client = spotify_client
self.plex_client = plex_client
self.soulseek_client = soulseek_client
self.current_playlists = []
self.playlist_loader = None
@ -1640,77 +1718,6 @@ class SyncPage(QWidget):
else:
self.playlist_layout.removeItem(item)
def create_download_progress_bubble(self, progress_info):
"""Create a progress bubble for ongoing downloads"""
if hasattr(self, 'progress_bubble'):
# Remove existing bubble
self.progress_bubble.deleteLater()
# Create bubble widget
self.progress_bubble = QFrame(self)
self.progress_bubble.setFixedSize(300, 80)
self.progress_bubble.setStyleSheet("""
QFrame {
background-color: #1db954;
border: 2px solid #169441;
border-radius: 15px;
color: #000000;
}
QLabel {
color: #000000;
font-weight: bold;
}
""")
# Create bubble layout
bubble_layout = QVBoxLayout(self.progress_bubble)
bubble_layout.setContentsMargins(10, 8, 10, 8)
bubble_layout.setSpacing(5)
# Title
title = QLabel(f"📥 {progress_info['playlist_name']}")
title.setFont(QFont("Arial", 11, QFont.Weight.Bold))
# Progress text
if progress_info['analysis_complete']:
progress_text = f"Downloading: {progress_info['download_progress']}/{progress_info['download_total']}"
else:
progress_text = f"Analyzing: {progress_info['analysis_progress']}/{progress_info['total_tracks']}"
progress_label = QLabel(progress_text)
progress_label.setFont(QFont("Arial", 9))
# Click to reopen
click_label = QLabel("Click to view details")
click_label.setFont(QFont("Arial", 8))
click_label.setStyleSheet("color: #666666;")
bubble_layout.addWidget(title)
bubble_layout.addWidget(progress_label)
bubble_layout.addWidget(click_label)
# Position bubble in top-right corner
self.progress_bubble.move(self.width() - 320, 20)
self.progress_bubble.show()
# Make bubble clickable
self.progress_bubble.mousePressEvent = lambda event: self.reopen_download_modal(progress_info['modal_reference'])
# Store reference for updates
self.progress_bubble.progress_info = progress_info
def reopen_download_modal(self, modal_reference):
"""Reopen the download modal from the progress bubble"""
if modal_reference and not modal_reference.isVisible():
# Remove bubble
if hasattr(self, 'progress_bubble'):
self.progress_bubble.deleteLater()
delattr(self, 'progress_bubble')
# Show modal
modal_reference.show()
modal_reference.activateWindow()
modal_reference.raise_()
class DownloadMissingTracksModal(QDialog):
@ -2320,6 +2327,7 @@ class DownloadMissingTracksModal(QDialog):
}
""")
self.cancel_btn.clicked.connect(self.on_cancel_clicked)
self.cancel_btn.hide() # Initially hidden
# Close button
self.close_btn = QPushButton("Close")
@ -2352,14 +2360,20 @@ class DownloadMissingTracksModal(QDialog):
def on_begin_search_clicked(self):
"""Handle Begin Search button click - starts Plex analysis"""
self.begin_search_btn.setEnabled(False)
self.begin_search_btn.setText("Searching Plex...")
# Hide Begin Search button and show Cancel button
self.begin_search_btn.hide()
self.cancel_btn.show()
# Show and reset analysis progress bar
self.analysis_progress.setVisible(True)
self.analysis_progress.setMaximum(self.total_tracks)
self.analysis_progress.setValue(0)
# Update playlist status indicator
playlist_item = self.find_playlist_item()
if playlist_item:
playlist_item.show_operation_status("🔍 Starting analysis...")
# Start Plex analysis
self.start_plex_analysis()
@ -2413,6 +2427,13 @@ class DownloadMissingTracksModal(QDialog):
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(track_index - 1, 3, matched_item)
# Update playlist status indicator
playlist_item = self.find_playlist_item()
if playlist_item:
total = len(self.playlist.tracks)
status_text = f"🔍 Analyzing {track_index}/{total}"
playlist_item.update_operation_status(status_text)
print(f" Track {track_index}: {result.spotify_track.name} - {'Found' if result.exists_in_plex else 'Missing'}")
def on_analysis_completed(self, results):
@ -2423,13 +2444,22 @@ class DownloadMissingTracksModal(QDialog):
print(f"✅ Analysis complete: {len(self.missing_tracks)} to download, {self.matched_tracks_count} matched")
# Update UI
self.begin_search_btn.setText("Analysis Complete")
if self.missing_tracks:
# Update playlist status for download phase
playlist_item = self.find_playlist_item()
if playlist_item:
status_text = f"⏬ Starting downloads..."
playlist_item.update_operation_status(status_text)
# Automatically start download progress
self.start_download_progress()
else:
# All tracks found - hide Cancel button and update status
self.cancel_btn.hide()
playlist_item = self.find_playlist_item()
if playlist_item:
playlist_item.hide_operation_status()
QMessageBox.information(self, "Analysis Complete",
"All tracks already exist in Plex library!\nNo downloads needed.")
@ -2438,7 +2468,9 @@ class DownloadMissingTracksModal(QDialog):
print(f"❌ Analysis failed: {error_message}")
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
# Reset UI
# Reset UI - show Begin Search again, hide Cancel
self.cancel_btn.hide()
self.begin_search_btn.show()
self.begin_search_btn.setEnabled(True)
self.begin_search_btn.setText("Begin Search")
self.analysis_progress.setVisible(False)
@ -2461,8 +2493,7 @@ class DownloadMissingTracksModal(QDialog):
return
# Get Soulseek client from parent
soulseek_client = getattr(self.parent_page, 'soulseek_client', None)
if not soulseek_client:
if not self.parent_page.soulseek_client:
QMessageBox.critical(self, "Soulseek Unavailable",
"Soulseek client not available. Please check your configuration.")
return
@ -2495,8 +2526,7 @@ class DownloadMissingTracksModal(QDialog):
self.track_table.setItem(track_index, 4, downloading_item)
# Create download worker
soulseek_client = getattr(self.parent_page, 'soulseek_client', None)
worker = TrackDownloadWorker(track, soulseek_client, self.current_download, track_index)
worker = TrackDownloadWorker(track, self.parent_page.soulseek_client, self.current_download, track_index)
worker.signals.download_completed.connect(self.on_track_download_complete)
worker.signals.download_failed.connect(self.on_track_download_failed)
@ -2534,6 +2564,13 @@ class DownloadMissingTracksModal(QDialog):
self.current_download += 1
self.download_progress.setValue(self.current_download)
# Update playlist status indicator
playlist_item = self.find_playlist_item()
if playlist_item:
total = len(self.missing_tracks) if hasattr(self, 'missing_tracks') else 0
status_text = f"⏬ Downloading {self.current_download}/{total}"
playlist_item.update_operation_status(status_text)
# Continue with next download
self.download_next_track()
@ -2560,31 +2597,92 @@ class DownloadMissingTracksModal(QDialog):
self.download_in_progress = False
print("🎉 All downloads completed!")
# Update button text
self.begin_search_btn.setText("Downloads Complete")
# Hide Cancel button - operations complete
self.cancel_btn.hide()
# Update playlist status indicator - operation complete
playlist_item = self.find_playlist_item()
if playlist_item:
playlist_item.hide_operation_status()
QMessageBox.information(self, "Downloads Complete",
f"Completed downloading {len(self.missing_tracks)} missing tracks!")
def create_progress_bubble(self):
"""Create a progress bubble on the main sync page"""
if hasattr(self.parent_page, 'create_download_progress_bubble'):
# Calculate current progress
total_tracks = len(self.playlist.tracks)
completed_analysis = self.analysis_progress.value() if self.analysis_complete else 0
completed_downloads = self.download_progress.value() if self.download_in_progress else 0
def setup_background_status_updates(self):
"""Set up timer-based background status updates for playlist indicator"""
from PyQt6.QtCore import QTimer
# Create a timer for background updates
if not hasattr(self, 'status_update_timer'):
self.status_update_timer = QTimer()
self.status_update_timer.timeout.connect(self.update_background_status)
self.status_update_timer.start(500) # Update every 500ms
def update_background_status(self):
"""Update playlist status in background"""
playlist_item = self.find_playlist_item()
if not playlist_item:
return
progress_info = {
'playlist_name': self.playlist.name,
'total_tracks': total_tracks,
'analysis_complete': self.analysis_complete,
'analysis_progress': completed_analysis,
'download_progress': completed_downloads,
'download_total': len(self.missing_tracks) if hasattr(self, 'missing_tracks') else 0,
'modal_reference': self # Keep reference to reopen modal
}
try:
# Update based on current progress state
if not self.analysis_complete:
# Still analyzing
if hasattr(self, 'analysis_progress'):
progress = self.analysis_progress.value()
total = len(self.playlist.tracks)
status_text = f"🔍 Analyzing {progress}/{total}"
playlist_item.update_operation_status(status_text)
elif self.download_in_progress:
# Downloading
if hasattr(self, 'download_progress'):
progress = self.download_progress.value()
total = len(self.missing_tracks) if hasattr(self, 'missing_tracks') else 0
status_text = f"⏬ Downloading {progress}/{total}"
playlist_item.update_operation_status(status_text)
else:
# Operations complete - stop timer and hide status
if hasattr(self, 'status_update_timer'):
self.status_update_timer.stop()
playlist_item.hide_operation_status()
except Exception as e:
print(f"Background status update error: {e}")
def update_playlist_status_indicator(self):
"""Update the playlist status indicator instead of creating a bubble"""
# Find the playlist item in the parent page
playlist_item = self.find_playlist_item()
if playlist_item:
# Determine current operation status
if not self.analysis_complete:
# Still analyzing
progress = self.analysis_progress.value()
total = len(self.playlist.tracks)
status_text = f"🔍 Analyzing {progress}/{total}"
elif self.download_in_progress:
# Downloading
progress = self.download_progress.value()
total = len(self.missing_tracks) if hasattr(self, 'missing_tracks') else 0
status_text = f"⏬ Downloading {progress}/{total}"
else:
status_text = "✅ Complete"
self.parent_page.create_download_progress_bubble(progress_info)
playlist_item.show_operation_status(status_text)
def find_playlist_item(self):
"""Find the PlaylistItem widget for this playlist"""
if not hasattr(self.parent_page, 'current_playlists'):
return None
# Look through the parent page's playlist items
for i in range(self.parent_page.playlist_layout.count()):
item = self.parent_page.playlist_layout.itemAt(i)
if item and item.widget() and isinstance(item.widget(), PlaylistItem):
playlist_item = item.widget()
if playlist_item.playlist and playlist_item.playlist.id == self.playlist.id:
return playlist_item
return None
def simulate_downloads(self):
"""Simulate download process (placeholder for real implementation)"""
@ -2633,9 +2731,9 @@ class DownloadMissingTracksModal(QDialog):
def on_close_clicked(self):
"""Handle Close button - closes modal without canceling operations"""
# If operations are in progress, create progress bubble
# If operations are in progress, set up background status updates
if self.download_in_progress or not self.analysis_complete:
self.create_progress_bubble()
self.setup_background_status_updates()
# Close modal without canceling operations
self.reject()
@ -2650,11 +2748,38 @@ class DownloadMissingTracksModal(QDialog):
# Stop timers
if hasattr(self, 'download_timer'):
self.download_timer.stop()
if hasattr(self, 'status_update_timer'):
self.status_update_timer.stop()
# Reset button states - hide Cancel, show Begin Search
self.cancel_btn.hide()
self.begin_search_btn.show()
self.begin_search_btn.setEnabled(True)
self.begin_search_btn.setText("Begin Search")
# Hide progress bars
self.analysis_progress.setVisible(False)
self.download_progress.setVisible(False)
# Reset state flags
self.analysis_complete = False
self.download_in_progress = False
# Reset playlist status indicator
playlist_item = self.find_playlist_item()
if playlist_item:
playlist_item.hide_operation_status()
print("🛑 Operations cancelled")
def closeEvent(self, event):
"""Handle modal close event"""
# If operations are still in progress when closing, set up background updates
if (self.download_in_progress or not self.analysis_complete) and not hasattr(self, 'background_timer_started'):
self.setup_background_status_updates()
self.background_timer_started = True
# Only cancel if user explicitly clicked Cancel
# For Close button or X button, preserve operations
event.accept()