soulsync/ui/pages/sync.py
Broque Thomas a429aa4dbd better
2025-07-21 21:49:37 -07:00

4626 lines
No EOL
194 KiB
Python

from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QFrame, QPushButton, QListWidget, QListWidgetItem,
QProgressBar, QTextEdit, QCheckBox, QComboBox,
QScrollArea, QSizePolicy, QMessageBox, QDialog,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject
from PyQt6.QtGui import QFont
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class TrackAnalysisResult:
"""Result of analyzing a track for Plex existence"""
spotify_track: object # Spotify track object
exists_in_plex: bool
plex_match: Optional[object] = None # Plex track if found
confidence: float = 0.0
error_message: Optional[str] = None
class PlaylistTrackAnalysisWorkerSignals(QObject):
"""Signals for playlist track analysis worker"""
analysis_started = pyqtSignal(int) # total_tracks
track_analyzed = pyqtSignal(int, object) # track_index, TrackAnalysisResult
analysis_completed = pyqtSignal(list) # List[TrackAnalysisResult]
analysis_failed = pyqtSignal(str) # error_message
class PlaylistTrackAnalysisWorker(QRunnable):
"""Background worker to analyze playlist tracks against Plex library"""
def __init__(self, playlist_tracks, plex_client):
super().__init__()
self.playlist_tracks = playlist_tracks
self.plex_client = plex_client
self.signals = PlaylistTrackAnalysisWorkerSignals()
self._cancelled = False
def cancel(self):
"""Cancel the analysis operation"""
self._cancelled = True
def run(self):
"""Analyze each track in the playlist"""
try:
if self._cancelled:
return
self.signals.analysis_started.emit(len(self.playlist_tracks))
results = []
# Check if Plex is connected
plex_connected = False
try:
if self.plex_client:
plex_connected = self.plex_client.is_connected()
except Exception as e:
print(f"Plex connection check failed: {e}")
plex_connected = False
for i, track in enumerate(self.playlist_tracks):
if self._cancelled:
return
result = TrackAnalysisResult(
spotify_track=track,
exists_in_plex=False
)
if plex_connected:
# Check if track exists in Plex
try:
plex_match, confidence = self._check_track_in_plex(track)
if plex_match and confidence >= 0.8: # High confidence threshold
result.exists_in_plex = True
result.plex_match = plex_match
result.confidence = confidence
except Exception as e:
result.error_message = f"Plex check failed: {str(e)}"
results.append(result)
self.signals.track_analyzed.emit(i + 1, result)
if not self._cancelled:
self.signals.analysis_completed.emit(results)
except Exception as e:
if not self._cancelled:
self.signals.analysis_failed.emit(str(e))
def _check_track_in_plex(self, spotify_track):
"""Check if a Spotify track exists in Plex with confidence scoring"""
try:
# Search Plex for similar tracks
# Use first artist for search
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
search_query = f"{artist_name} {spotify_track.name}".strip()
# Get potential matches from Plex
plex_tracks = self.plex_client.search_tracks(search_query, limit=10)
if not plex_tracks:
return None, 0.0
# Find best match using confidence scoring
best_match = None
best_confidence = 0.0
for plex_track in plex_tracks:
confidence = self._calculate_track_confidence(spotify_track, plex_track)
if confidence > best_confidence:
best_confidence = confidence
best_match = plex_track
return best_match, best_confidence
except Exception as e:
print(f"Error checking track in Plex: {e}")
return None, 0.0
def _calculate_track_confidence(self, spotify_track, plex_track):
"""Calculate confidence score between Spotify and Plex tracks"""
try:
# Basic string similarity for now (can be enhanced with existing matching engine)
import re
def normalize_string(s):
return re.sub(r'[^a-zA-Z0-9\s]', '', s.lower()).strip()
# Normalize track titles
spotify_title = normalize_string(spotify_track.name)
plex_title = normalize_string(plex_track.title)
# Normalize artist names
spotify_artist = normalize_string(spotify_track.artists[0]) if spotify_track.artists else ""
plex_artist = normalize_string(plex_track.artist)
# Simple similarity scoring
title_similarity = 1.0 if spotify_title == plex_title else 0.0
artist_similarity = 1.0 if spotify_artist == plex_artist else 0.0
# Weight title more heavily
confidence = (title_similarity * 0.7) + (artist_similarity * 0.3)
# Duration check (allow 10% variance)
if hasattr(spotify_track, 'duration_ms') and hasattr(plex_track, 'duration'):
spotify_duration = spotify_track.duration_ms / 1000
plex_duration = plex_track.duration / 1000 if plex_track.duration else 0
if plex_duration > 0:
duration_diff = abs(spotify_duration - plex_duration) / max(spotify_duration, plex_duration)
if duration_diff <= 0.1: # Within 10%
confidence += 0.1 # Bonus for duration match
return min(confidence, 1.0) # Cap at 1.0
except Exception as e:
print(f"Error calculating track confidence: {e}")
return 0.0
class TrackDownloadWorkerSignals(QObject):
"""Signals for track download worker"""
download_completed = pyqtSignal(int, int, str) # download_index, track_index, download_id
download_failed = pyqtSignal(int, int, str) # download_index, track_index, error_message
class TrackDownloadWorker(QRunnable):
"""Background worker to download individual tracks via Soulseek"""
def __init__(self, spotify_track, soulseek_client, download_index, track_index):
super().__init__()
self.spotify_track = spotify_track
self.soulseek_client = soulseek_client
self.download_index = download_index
self.track_index = track_index
self.signals = TrackDownloadWorkerSignals()
self._cancelled = False
def cancel(self):
"""Cancel the download operation"""
self._cancelled = True
def run(self):
"""Download the track via Soulseek"""
try:
if self._cancelled or not self.soulseek_client:
return
# Create search queries - try track name first, then artist + track
track_name = self.spotify_track.name
artist_name = self.spotify_track.artists[0] if self.spotify_track.artists else ""
search_queries = []
search_queries.append(track_name) # Try track name only first
if artist_name:
search_queries.append(f"{artist_name} {track_name}") # Then artist + track
download_id = None
# Try each search query until we find a download
for query in search_queries:
if self._cancelled:
return
print(f"🔍 Searching Soulseek: {query}")
# Use the async method (need to run in sync context)
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
download_id = loop.run_until_complete(
self.soulseek_client.search_and_download_best(query)
)
if download_id:
break # Success - stop trying other queries
finally:
loop.close()
if download_id:
self.signals.download_completed.emit(self.download_index, self.track_index, download_id)
else:
self.signals.download_failed.emit(self.download_index, self.track_index, "No search results found")
except Exception as e:
self.signals.download_failed.emit(self.download_index, self.track_index, str(e))
class PlaylistLoaderThread(QThread):
playlist_loaded = pyqtSignal(object) # Single playlist
loading_finished = pyqtSignal(int) # Total count
loading_failed = pyqtSignal(str) # Error message
progress_updated = pyqtSignal(str) # Progress text
def __init__(self, spotify_client):
super().__init__()
self.spotify_client = spotify_client
def run(self):
try:
self.progress_updated.emit("Connecting to Spotify...")
if not self.spotify_client or not self.spotify_client.is_authenticated():
self.loading_failed.emit("Spotify not authenticated")
return
self.progress_updated.emit("Fetching playlists...")
playlists = self.spotify_client.get_user_playlists_metadata_only()
for i, playlist in enumerate(playlists):
self.progress_updated.emit(f"Loading playlist {i+1}/{len(playlists)}: {playlist.name}")
self.playlist_loaded.emit(playlist)
self.msleep(20) # Reduced delay for faster but visible progressive loading
self.loading_finished.emit(len(playlists))
except Exception as e:
self.loading_failed.emit(str(e))
class TrackLoadingWorkerSignals(QObject):
"""Signals for async track loading worker"""
tracks_loaded = pyqtSignal(str, list) # playlist_id, tracks
loading_failed = pyqtSignal(str, str) # playlist_id, error_message
loading_started = pyqtSignal(str) # playlist_id
class TrackLoadingWorker(QRunnable):
"""Async worker for loading playlist tracks (following downloads.py pattern)"""
def __init__(self, spotify_client, playlist_id, playlist_name):
super().__init__()
self.spotify_client = spotify_client
self.playlist_id = playlist_id
self.playlist_name = playlist_name
self.signals = TrackLoadingWorkerSignals()
self._cancelled = False
def cancel(self):
"""Cancel the worker operation"""
self._cancelled = True
def run(self):
"""Load tracks in background thread"""
try:
if self._cancelled:
return
self.signals.loading_started.emit(self.playlist_id)
if self._cancelled:
return
# Fetch tracks from Spotify API
tracks = self.spotify_client._get_playlist_tracks(self.playlist_id)
if self._cancelled:
return
# Emit success signal
self.signals.tracks_loaded.emit(self.playlist_id, tracks)
except Exception as e:
if not self._cancelled:
# Emit error signal only if not cancelled
self.signals.loading_failed.emit(self.playlist_id, str(e))
class PlaylistDetailsModal(QDialog):
def __init__(self, playlist, parent=None):
super().__init__(parent)
self.playlist = playlist
self.parent_page = parent
self.spotify_client = parent.spotify_client if parent else None
# Thread management
self.active_workers = []
self.fallback_pools = []
self.is_closing = False
self.setup_ui()
# Load tracks asynchronously if not already cached
if not self.playlist.tracks and self.spotify_client:
# Check cache first
if hasattr(parent, 'track_cache') and playlist.id in parent.track_cache:
self.playlist.tracks = parent.track_cache[playlist.id]
self.refresh_track_table()
else:
self.load_tracks_async()
def closeEvent(self, event):
"""Clean up threads and resources when modal is closed"""
self.is_closing = True
self.cleanup_workers()
super().closeEvent(event)
def cleanup_workers(self):
"""Clean up all active workers and thread pools"""
# Cancel active workers first
for worker in self.active_workers:
try:
if hasattr(worker, 'cancel'):
worker.cancel()
except (RuntimeError, AttributeError):
pass
# Disconnect signals from active workers to prevent race conditions
for worker in self.active_workers:
try:
if hasattr(worker, 'signals'):
# Disconnect track loading worker signals
try:
worker.signals.tracks_loaded.disconnect(self.on_tracks_loaded)
except (RuntimeError, TypeError):
pass
try:
worker.signals.loading_failed.disconnect(self.on_tracks_loading_failed)
except (RuntimeError, TypeError):
pass
# Disconnect playlist analysis worker signals
try:
worker.signals.analysis_started.disconnect(self.on_analysis_started)
except (RuntimeError, TypeError):
pass
try:
worker.signals.track_analyzed.disconnect(self.on_track_analyzed)
except (RuntimeError, TypeError):
pass
try:
worker.signals.analysis_completed.disconnect(self.on_analysis_completed)
except (RuntimeError, TypeError):
pass
try:
worker.signals.analysis_failed.disconnect(self.on_analysis_failed)
except (RuntimeError, TypeError):
pass
except (RuntimeError, AttributeError):
# Signal may already be disconnected or worker deleted
pass
# Clean up fallback thread pools with timeout
for pool in self.fallback_pools:
try:
pool.clear() # Cancel pending workers
if not pool.waitForDone(2000): # Wait 2 seconds max
# Force termination if workers don't finish gracefully
pool.clear()
except (RuntimeError, AttributeError):
pass
# Clear tracking lists
self.active_workers.clear()
self.fallback_pools.clear()
def setup_ui(self):
self.setWindowTitle(f"Playlist Details - {self.playlist.name}")
self.setFixedSize(900, 700)
self.setStyleSheet("""
QDialog {
background: #191414;
color: #ffffff;
}
""")
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(30, 30, 30, 30)
main_layout.setSpacing(20)
# Header section
header = self.create_header()
main_layout.addWidget(header)
# Track list section
track_list = self.create_track_list()
main_layout.addWidget(track_list)
# Button section
button_widget = QWidget()
button_layout = self.create_buttons()
button_widget.setLayout(button_layout)
main_layout.addWidget(button_widget)
def create_header(self):
header = QFrame()
header.setStyleSheet("""
QFrame {
background: #282828;
border-radius: 12px;
border: 1px solid #404040;
}
""")
layout = QVBoxLayout(header)
layout.setContentsMargins(25, 20, 25, 20)
layout.setSpacing(10)
# Playlist name
name_label = QLabel(self.playlist.name)
name_label.setFont(QFont("Arial", 20, QFont.Weight.Bold))
name_label.setStyleSheet("color: #ffffff; border: none; background: transparent;")
# Playlist info
info_layout = QHBoxLayout()
info_layout.setSpacing(20)
# Track count
track_count = QLabel(f"{self.playlist.total_tracks} tracks")
track_count.setFont(QFont("Arial", 12))
track_count.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
# Owner
owner = QLabel(f"by {self.playlist.owner}")
owner.setFont(QFont("Arial", 12))
owner.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
# Public/Private status
visibility = "Public" if self.playlist.public else "Private"
if self.playlist.collaborative:
visibility = "Collaborative"
status = QLabel(visibility)
status.setFont(QFont("Arial", 12))
status.setStyleSheet("color: #1db954; border: none; background: transparent;")
info_layout.addWidget(track_count)
info_layout.addWidget(owner)
info_layout.addWidget(status)
info_layout.addStretch()
# Description (if available)
if self.playlist.description:
desc_label = QLabel(self.playlist.description)
desc_label.setFont(QFont("Arial", 11))
desc_label.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
desc_label.setWordWrap(True)
desc_label.setMaximumHeight(60)
layout.addWidget(desc_label)
layout.addWidget(name_label)
layout.addLayout(info_layout)
return header
def create_track_list(self):
container = QFrame()
container.setStyleSheet("""
QFrame {
background: #282828;
border-radius: 12px;
border: 1px solid #404040;
}
""")
layout = QVBoxLayout(container)
layout.setContentsMargins(25, 20, 25, 20)
layout.setSpacing(15)
# Section title
title = QLabel("Tracks")
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
title.setStyleSheet("color: #ffffff; border: none; background: transparent;")
# Track table
self.track_table = QTableWidget()
self.track_table.setColumnCount(4)
self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Album", "Duration"])
# Set initial row count (may be 0 if tracks not loaded yet)
track_count = len(self.playlist.tracks) if self.playlist.tracks else 1
self.track_table.setRowCount(track_count)
# Style the table
self.track_table.setStyleSheet("""
QTableWidget {
background: #181818;
border: 1px solid #404040;
border-radius: 8px;
gridline-color: #404040;
color: #ffffff;
font-size: 11px;
}
QTableWidget::item {
padding: 8px;
border-bottom: 1px solid #333333;
}
QTableWidget::item:selected {
background: #1db954;
color: #000000;
}
QHeaderView::section {
background: #404040;
color: #ffffff;
padding: 10px;
border: none;
font-weight: bold;
font-size: 11px;
}
""")
# Populate table
if self.playlist.tracks:
for row, track in enumerate(self.playlist.tracks):
# Track name
track_item = QTableWidgetItem(track.name)
track_item.setFlags(track_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(row, 0, track_item)
# Artist(s)
artists = ", ".join(track.artists)
artist_item = QTableWidgetItem(artists)
artist_item.setFlags(artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(row, 1, artist_item)
# Album
album_item = QTableWidgetItem(track.album)
album_item.setFlags(album_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(row, 2, album_item)
# Duration
duration = self.format_duration(track.duration_ms)
duration_item = QTableWidgetItem(duration)
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(row, 3, duration_item)
else:
# Show placeholder while tracks are being loaded
placeholder_item = QTableWidgetItem("Tracks will load momentarily...")
placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(0, 0, placeholder_item)
self.track_table.setSpan(0, 0, 1, 4)
# Set optimal column widths with proportional sizing
header = self.track_table.horizontalHeader()
header.setStretchLastSection(False)
# Calculate available width (modal is 900px, account for margins/scrollbar)
available_width = 850
# Set proportional widths: Track(40%), Artist(25%), Album(25%), Duration(10%)
track_width = int(available_width * 0.40) # ~340px
artist_width = int(available_width * 0.25) # ~212px
album_width = int(available_width * 0.25) # ~212px
duration_width = 80 # Fixed 80px
# Apply column widths with interactive resize capability
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) # Track name
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # Artist
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # Album
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Duration
self.track_table.setColumnWidth(0, track_width)
self.track_table.setColumnWidth(1, artist_width)
self.track_table.setColumnWidth(2, album_width)
self.track_table.setColumnWidth(3, duration_width)
# Set minimum widths to prevent columns from becoming too narrow
header.setMinimumSectionSize(100) # Minimum 100px for any column
# Hide row numbers
self.track_table.verticalHeader().setVisible(False)
layout.addWidget(title)
layout.addWidget(self.track_table)
return container
def create_buttons(self):
button_layout = QHBoxLayout()
button_layout.setSpacing(15)
# Close button
close_btn = QPushButton("Close")
close_btn.setFixedSize(100, 40)
close_btn.clicked.connect(self.close)
close_btn.setStyleSheet("""
QPushButton {
background: #404040;
border: none;
border-radius: 20px;
color: #ffffff;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background: #505050;
}
""")
# Sync button
sync_btn = QPushButton("Sync This Playlist")
sync_btn.setFixedSize(150, 40)
sync_btn.setStyleSheet("""
QPushButton {
background: #1db954;
border: none;
border-radius: 20px;
color: #000000;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background: #1ed760;
}
""")
# Download missing tracks button
download_btn = QPushButton("Download Missing Tracks")
download_btn.setFixedSize(180, 40)
download_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid #1db954;
border-radius: 20px;
color: #1db954;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background: rgba(29, 185, 84, 0.1);
}
""")
# Connect download missing tracks button
download_btn.clicked.connect(self.on_download_missing_tracks_clicked)
# Connect sync button
sync_btn.clicked.connect(self.on_sync_playlist_clicked)
button_layout.addStretch()
button_layout.addWidget(close_btn)
button_layout.addWidget(download_btn)
button_layout.addWidget(sync_btn)
return button_layout
def format_duration(self, duration_ms):
"""Convert milliseconds to MM:SS format"""
seconds = duration_ms // 1000
minutes = seconds // 60
seconds = seconds % 60
return f"{minutes}:{seconds:02d}"
def on_download_missing_tracks_clicked(self):
"""Handle Download Missing Tracks button click"""
print("🔄 Download Missing Tracks button clicked!")
if not self.playlist:
print("❌ No playlist selected")
QMessageBox.warning(self, "Error", "No playlist selected")
return
if not self.playlist.tracks:
print("❌ Playlist tracks not loaded")
QMessageBox.warning(self, "Error", "Playlist tracks not loaded")
return
print(f"✅ Playlist: {self.playlist.name} with {len(self.playlist.tracks)} tracks")
# Get access to parent's Plex and Soulseek clients through parent reference
if not hasattr(self.parent_page, 'plex_client'):
print("❌ Plex client not available")
QMessageBox.warning(self, "Service Unavailable",
"Plex client not available. Please check your configuration.")
return
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:
print("🚀 Creating modal...")
modal = DownloadMissingTracksModal(self.playlist, self.parent_page, self, self.parent_page.downloads_page)
print("✅ Modal created successfully")
# 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
print("🖥️ Showing download modal...")
result = modal.exec()
print(f"✅ Modal closed with result: {result}")
except Exception as e:
print(f"❌ Exception creating modal: {e}")
import traceback
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 on_sync_playlist_clicked(self):
"""Handle Sync This Playlist button click"""
if not self.playlist:
QMessageBox.warning(self, "Error", "No playlist selected")
return
if not self.playlist.tracks:
QMessageBox.warning(self, "Error", "Playlist tracks not loaded")
return
# Check if sync service is available
if not hasattr(self.parent_page, 'sync_service'):
# Create sync service if not available
from services.sync_service import PlaylistSyncService
self.parent_page.sync_service = PlaylistSyncService(
self.parent_page.spotify_client,
self.parent_page.plex_client,
self.parent_page.soulseek_client
)
# Set up progress callback to update console
self.parent_page.sync_service.set_progress_callback(self.on_sync_progress)
# Add initial console log
self.parent_page.log_area.append(f"🔄 Starting sync for playlist: {self.playlist.name}")
# Start sync in background thread
self.start_sync_thread()
# Close modal to return to main view
self.accept()
def on_sync_progress(self, progress):
"""Handle sync progress updates and forward to console"""
if hasattr(self.parent_page, 'log_area'):
progress_msg = f"{progress.current_step}"
if progress.current_track:
progress_msg += f" - {progress.current_track}"
progress_msg += f" ({progress.progress:.1f}%)"
self.parent_page.log_area.append(progress_msg)
def start_sync_thread(self):
"""Start playlist sync in a background thread"""
import asyncio
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
class SyncWorkerSignals(QObject):
finished = pyqtSignal(object) # SyncResult
error = pyqtSignal(str)
class SyncWorker(QRunnable):
def __init__(self, sync_service, playlist_name):
super().__init__()
self.sync_service = sync_service
self.playlist_name = playlist_name
self.signals = SyncWorkerSignals()
def run(self):
try:
# Create new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Run sync
result = loop.run_until_complete(
self.sync_service.sync_playlist(self.playlist_name, download_missing=False)
)
loop.close()
self.signals.finished.emit(result)
except Exception as e:
self.signals.error.emit(str(e))
# Create and start worker
worker = SyncWorker(self.parent_page.sync_service, self.playlist.name)
worker.signals.finished.connect(self.on_sync_finished)
worker.signals.error.connect(self.on_sync_error)
# Submit to thread pool
if hasattr(self.parent_page, 'thread_pool'):
self.parent_page.thread_pool.start(worker)
else:
# Create fallback thread pool
thread_pool = QThreadPool()
thread_pool.start(worker)
def on_sync_finished(self, result):
"""Handle sync completion"""
if hasattr(self.parent_page, 'log_area'):
success_rate = result.success_rate
msg = f"✅ Sync complete: {result.synced_tracks}/{result.total_tracks} tracks synced ({success_rate:.1f}%)"
if result.failed_tracks > 0:
msg += f", {result.failed_tracks} failed"
self.parent_page.log_area.append(msg)
# Add detailed results
if result.errors:
for error in result.errors:
self.parent_page.log_area.append(f"❌ Error: {error}")
def on_sync_error(self, error):
"""Handle sync error"""
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f"❌ Sync failed: {error}")
QMessageBox.critical(self, "Sync Error", f"Sync failed: {error}")
def start_playlist_missing_tracks_download(self):
"""Start the process of downloading missing tracks from playlist"""
track_count = len(self.playlist.tracks)
# Start analysis worker
self.start_track_analysis()
# Show analysis started message
QMessageBox.information(self, "Analysis Started",
f"Starting analysis of {track_count} tracks.\nChecking Plex library for existing tracks...")
def start_track_analysis(self):
"""Start background track analysis against Plex library"""
# Create analysis worker
plex_client = getattr(self.parent_page, 'plex_client', None)
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, plex_client)
# Connect signals
worker.signals.analysis_started.connect(self.on_analysis_started)
worker.signals.track_analyzed.connect(self.on_track_analyzed)
worker.signals.analysis_completed.connect(self.on_analysis_completed)
worker.signals.analysis_failed.connect(self.on_analysis_failed)
# Track worker for cleanup
self.active_workers.append(worker)
# Submit to thread pool
if hasattr(self.parent_page, 'thread_pool'):
self.parent_page.thread_pool.start(worker)
else:
# Create and track fallback thread pool
thread_pool = QThreadPool()
self.fallback_pools.append(thread_pool)
thread_pool.start(worker)
def on_analysis_started(self, total_tracks):
"""Handle analysis started signal"""
print(f"Started analyzing {total_tracks} tracks against Plex library")
def on_track_analyzed(self, track_index, result):
"""Handle individual track analysis completion"""
track = result.spotify_track
if result.exists_in_plex:
print(f"Track {track_index}: '{track.name}' by {track.artists[0]} EXISTS in Plex (confidence: {result.confidence:.2f})")
else:
print(f"Track {track_index}: '{track.name}' by {track.artists[0]} MISSING from Plex - will download")
def on_analysis_completed(self, results):
"""Handle analysis completion and start downloads for missing tracks"""
missing_tracks = [r for r in results if not r.exists_in_plex]
existing_tracks = [r for r in results if r.exists_in_plex]
print(f"Analysis complete: {len(missing_tracks)} missing, {len(existing_tracks)} existing")
if not missing_tracks:
QMessageBox.information(self, "Analysis Complete",
"All tracks already exist in Plex library!\nNo downloads needed.")
return
# Show results to user
message = f"Analysis complete!\n\n"
message += f"Tracks already in Plex: {len(existing_tracks)}\n"
message += f"Tracks to download: {len(missing_tracks)}\n\n"
message += "Ready to start downloading missing tracks?"
reply = QMessageBox.question(self, "Start Downloads?", message,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
self.start_missing_track_downloads(missing_tracks)
def on_analysis_failed(self, error_message):
"""Handle analysis failure"""
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
def start_missing_track_downloads(self, missing_tracks):
"""Start downloading the missing tracks"""
# TODO: Implement Soulseek search and download queueing
# For now, just show what would be downloaded
track_list = []
for result in missing_tracks:
track = result.spotify_track
artist = track.artists[0] if track.artists else "Unknown Artist"
track_list.append(f"{track.name} by {artist}")
message = f"Would download {len(missing_tracks)} tracks:\n\n"
message += "\n".join(track_list[:10]) # Show first 10
if len(track_list) > 10:
message += f"\n... and {len(track_list) - 10} more"
QMessageBox.information(self, "Downloads Queued", message)
def load_tracks_async(self):
"""Load tracks asynchronously using worker thread"""
if not self.spotify_client:
return
# Show loading state in track table
if hasattr(self, 'track_table'):
self.track_table.setRowCount(1)
loading_item = QTableWidgetItem("Loading tracks...")
loading_item.setFlags(loading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(0, 0, loading_item)
self.track_table.setSpan(0, 0, 1, 4)
# Create and submit worker to thread pool
worker = TrackLoadingWorker(self.spotify_client, self.playlist.id, self.playlist.name)
worker.signals.tracks_loaded.connect(self.on_tracks_loaded)
worker.signals.loading_failed.connect(self.on_tracks_loading_failed)
# Track active worker for cleanup
self.active_workers.append(worker)
# Submit to parent's thread pool if available, otherwise create one
if hasattr(self.parent_page, 'thread_pool'):
self.parent_page.thread_pool.start(worker)
else:
# Create and track fallback thread pool
thread_pool = QThreadPool()
self.fallback_pools.append(thread_pool)
thread_pool.start(worker)
def on_tracks_loaded(self, playlist_id, tracks):
"""Handle successful track loading"""
# Validate modal state before processing
if (playlist_id == self.playlist.id and
not self.is_closing and
not self.isHidden() and
hasattr(self, 'track_table')):
self.playlist.tracks = tracks
# Cache tracks in parent for future use
if hasattr(self.parent_page, 'track_cache'):
self.parent_page.track_cache[playlist_id] = tracks
# Refresh the track table
self.refresh_track_table()
def on_tracks_loading_failed(self, playlist_id, error_message):
"""Handle track loading failure"""
# Validate modal state before processing
if (playlist_id == self.playlist.id and
not self.is_closing and
not self.isHidden() and
hasattr(self, 'track_table')):
self.track_table.setRowCount(1)
error_item = QTableWidgetItem(f"Error loading tracks: {error_message}")
error_item.setFlags(error_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(0, 0, error_item)
self.track_table.setSpan(0, 0, 1, 4)
def refresh_track_table(self):
"""Refresh the track table with loaded tracks"""
if not hasattr(self, 'track_table'):
return
self.track_table.setRowCount(len(self.playlist.tracks))
self.track_table.clearSpans() # Remove any spans from loading state
# Populate table
for row, track in enumerate(self.playlist.tracks):
# Track name
track_item = QTableWidgetItem(track.name)
track_item.setFlags(track_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(row, 0, track_item)
# Artist(s)
artists = ", ".join(track.artists)
artist_item = QTableWidgetItem(artists)
artist_item.setFlags(artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(row, 1, artist_item)
# Album
album_item = QTableWidgetItem(track.album)
album_item.setFlags(album_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(row, 2, album_item)
# Duration
duration = self.format_duration(track.duration_ms)
duration_item = QTableWidgetItem(duration)
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(row, 3, duration_item)
class PlaylistItem(QFrame):
view_details_clicked = pyqtSignal(object) # Signal to emit playlist object
def __init__(self, name: str, track_count: int, sync_status: str, playlist=None, parent=None):
super().__init__(parent)
self.name = name
self.track_count = track_count
self.sync_status = sync_status
self.playlist = playlist # Store playlist object reference
self.is_selected = False
self.setup_ui()
def setup_ui(self):
self.setFixedHeight(80)
self.setStyleSheet("""
PlaylistItem {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
PlaylistItem:hover {
background: #333333;
border: 1px solid #1db954;
}
""")
layout = QHBoxLayout(self)
layout.setContentsMargins(20, 15, 20, 15)
layout.setSpacing(15)
# Checkbox
self.checkbox = QCheckBox()
self.checkbox.setStyleSheet("""
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #b3b3b3;
background: transparent;
}
QCheckBox::indicator:checked {
background: #1db954;
border: 2px solid #1db954;
}
QCheckBox::indicator:checked:hover {
background: #1ed760;
}
""")
# Content layout
content_layout = QVBoxLayout()
content_layout.setSpacing(5)
# Playlist name
name_label = QLabel(self.name)
name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
name_label.setStyleSheet("color: #ffffff;")
# Track count and status
info_layout = QHBoxLayout()
info_layout.setSpacing(20)
track_label = QLabel(f"{self.track_count} tracks")
track_label.setFont(QFont("Arial", 10))
track_label.setStyleSheet("color: #b3b3b3;")
status_label = QLabel(self.sync_status)
status_label.setFont(QFont("Arial", 10))
if self.sync_status == "Synced":
status_label.setStyleSheet("color: #1db954;")
elif self.sync_status == "Needs Sync":
status_label.setStyleSheet("color: #ffa500;")
else:
status_label.setStyleSheet("color: #e22134;")
info_layout.addWidget(track_label)
info_layout.addWidget(status_label)
info_layout.addStretch()
content_layout.addWidget(name_label)
content_layout.addLayout(info_layout)
# 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;
border-radius: 15px;
color: #1db954;
font-size: 10px;
font-weight: bold;
}
QPushButton:hover {
background: #1db954;
color: #000000;
}
""")
# 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(self.action_btn)
layout.addWidget(self.status_label)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.checkbox.setChecked(not self.checkbox.isChecked())
super().mousePressEvent(event)
def on_view_details_clicked(self):
"""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):
super().__init__(parent)
self.setup_ui()
def setup_ui(self):
self.setStyleSheet("""
SyncOptionsPanel {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)
# Title
title_label = QLabel("Sync Options")
title_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
title_label.setStyleSheet("color: #ffffff;")
# Download missing tracks option
self.download_missing = QCheckBox("Download missing tracks from Soulseek")
self.download_missing.setChecked(True)
self.download_missing.setStyleSheet("""
QCheckBox {
color: #ffffff;
font-size: 11px;
}
QCheckBox::indicator {
width: 16px;
height: 16px;
border-radius: 8px;
border: 2px solid #b3b3b3;
background: transparent;
}
QCheckBox::indicator:checked {
background: #1db954;
border: 2px solid #1db954;
}
""")
# Quality selection
quality_layout = QHBoxLayout()
quality_label = QLabel("Preferred Quality:")
quality_label.setStyleSheet("color: #b3b3b3; font-size: 11px;")
self.quality_combo = QComboBox()
self.quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "Any"])
self.quality_combo.setCurrentText("FLAC")
self.quality_combo.setStyleSheet("""
QComboBox {
background: #404040;
border: 1px solid #606060;
border-radius: 4px;
padding: 5px;
color: #ffffff;
font-size: 11px;
}
QComboBox::drop-down {
border: none;
}
QComboBox::down-arrow {
image: none;
border: none;
}
""")
quality_layout.addWidget(quality_label)
quality_layout.addWidget(self.quality_combo)
quality_layout.addStretch()
layout.addWidget(title_label)
layout.addWidget(self.download_missing)
layout.addLayout(quality_layout)
class SyncPage(QWidget):
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, parent=None):
super().__init__(parent)
self.spotify_client = spotify_client
self.plex_client = plex_client
self.soulseek_client = soulseek_client
self.downloads_page = downloads_page
self.current_playlists = []
self.playlist_loader = None
# Track cache for performance
self.track_cache = {} # playlist_id -> tracks
# Thread pool for async operations (like downloads.py)
self.thread_pool = QThreadPool()
self.thread_pool.setMaxThreadCount(3) # Limit concurrent Spotify API calls
self.setup_ui()
# Don't auto-load on startup, but do auto-load when page becomes visible
self.show_initial_state()
self.playlists_loaded = False
def showEvent(self, event):
"""Auto-load playlists when page becomes visible (but not during app startup)"""
super().showEvent(event)
# Only auto-load once and only if we have a spotify client
if (not self.playlists_loaded and
self.spotify_client and
self.spotify_client.is_authenticated()):
# Small delay to ensure UI is fully rendered
QTimer.singleShot(100, self.auto_load_playlists)
def auto_load_playlists(self):
"""Auto-load playlists with proper UI transition"""
# Clear the welcome state first
self.clear_playlists()
# Start loading (this will set playlists_loaded = True)
self.load_playlists_async()
def show_initial_state(self):
"""Show initial state with option to load playlists"""
# Add welcome message to playlist area
welcome_message = QLabel("Ready to sync playlists!\nClick 'Load Playlists' to get started.")
welcome_message.setAlignment(Qt.AlignmentFlag.AlignCenter)
welcome_message.setStyleSheet("""
QLabel {
color: #b3b3b3;
font-size: 16px;
padding: 60px;
background: #282828;
border-radius: 12px;
border: 1px solid #404040;
line-height: 1.5;
}
""")
# Add load button
load_btn = QPushButton("🎵 Load Playlists")
load_btn.setFixedSize(200, 50)
load_btn.clicked.connect(self.load_playlists_async)
load_btn.setStyleSheet("""
QPushButton {
background: #1db954;
border: none;
border-radius: 25px;
color: #000000;
font-size: 14px;
font-weight: bold;
margin-top: 20px;
}
QPushButton:hover {
background: #1ed760;
}
""")
# Add them to the playlist layout
if hasattr(self, 'playlist_layout'):
self.playlist_layout.addWidget(welcome_message)
self.playlist_layout.addWidget(load_btn)
self.playlist_layout.addStretch()
def setup_ui(self):
self.setStyleSheet("""
SyncPage {
background: #191414;
}
""")
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(30, 30, 30, 30)
main_layout.setSpacing(25)
# Header
header = self.create_header()
main_layout.addWidget(header)
# Content area
content_layout = QHBoxLayout()
content_layout.setSpacing(15) # Reduced from 25 to 15 for tighter spacing
# Left side - Playlist list
playlist_section = self.create_playlist_section()
content_layout.addWidget(playlist_section, 2)
# Right side - Options and actions
right_sidebar = self.create_right_sidebar()
content_layout.addWidget(right_sidebar, 1)
main_layout.addLayout(content_layout, 1) # Allow content to stretch
def create_header(self):
header = QWidget()
layout = QVBoxLayout(header)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
# Title
title_label = QLabel("Playlist Sync")
title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold))
title_label.setStyleSheet("color: #ffffff;")
# Subtitle
subtitle_label = QLabel("Synchronize your Spotify playlists with Plex")
subtitle_label.setFont(QFont("Arial", 14))
subtitle_label.setStyleSheet("color: #b3b3b3;")
layout.addWidget(title_label)
layout.addWidget(subtitle_label)
return header
def create_playlist_section(self):
section = QWidget()
layout = QVBoxLayout(section)
layout.setSpacing(15)
# Section header
header_layout = QHBoxLayout()
section_title = QLabel("Spotify Playlists")
section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
section_title.setStyleSheet("color: #ffffff;")
self.refresh_btn = QPushButton("🔄 Refresh")
self.refresh_btn.setFixedSize(100, 35)
self.refresh_btn.clicked.connect(self.load_playlists_async)
self.refresh_btn.setStyleSheet("""
QPushButton {
background: #1db954;
border: none;
border-radius: 17px;
color: #000000;
font-size: 11px;
font-weight: bold;
}
QPushButton:hover {
background: #1ed760;
}
QPushButton:pressed {
background: #1aa34a;
}
""")
header_layout.addWidget(section_title)
header_layout.addStretch()
header_layout.addWidget(self.refresh_btn)
# Playlist container
playlist_container = QScrollArea()
playlist_container.setWidgetResizable(True)
playlist_container.setStyleSheet("""
QScrollArea {
border: none;
background: transparent;
}
QScrollBar:vertical {
background: #282828;
width: 8px;
border-radius: 4px;
}
QScrollBar::handle:vertical {
background: #1db954;
border-radius: 4px;
}
""")
self.playlist_widget = QWidget()
self.playlist_layout = QVBoxLayout(self.playlist_widget)
self.playlist_layout.setSpacing(10)
# Playlists will be loaded asynchronously after UI setup
self.playlist_layout.addStretch()
playlist_container.setWidget(self.playlist_widget)
layout.addLayout(header_layout)
layout.addWidget(playlist_container)
return section
def create_right_sidebar(self):
section = QWidget()
layout = QVBoxLayout(section)
layout.setSpacing(20)
# Sync options
options_panel = SyncOptionsPanel()
layout.addWidget(options_panel)
# Action buttons
actions_frame = QFrame()
actions_frame.setStyleSheet("""
QFrame {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
""")
actions_layout = QVBoxLayout(actions_frame)
actions_layout.setContentsMargins(20, 20, 20, 20)
actions_layout.setSpacing(15)
# Sync button
sync_btn = QPushButton("Start Sync")
sync_btn.setFixedHeight(45)
sync_btn.setStyleSheet("""
QPushButton {
background: #1db954;
border: none;
border-radius: 22px;
color: #000000;
font-size: 14px;
font-weight: bold;
}
QPushButton:hover {
background: #1ed760;
}
QPushButton:pressed {
background: #1aa34a;
}
""")
# Preview button
preview_btn = QPushButton("Preview Changes")
preview_btn.setFixedHeight(35)
preview_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid #1db954;
border-radius: 17px;
color: #1db954;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background: rgba(29, 185, 84, 0.1);
}
""")
actions_layout.addWidget(sync_btn)
actions_layout.addWidget(preview_btn)
layout.addWidget(actions_frame)
# Progress section below buttons
progress_section = self.create_progress_section()
layout.addWidget(progress_section, 1) # Allow progress section to stretch
return section
def create_progress_section(self):
section = QFrame()
section.setMinimumHeight(200) # Set minimum height instead of fixed
section.setStyleSheet("""
QFrame {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
""")
layout = QVBoxLayout(section)
layout.setContentsMargins(20, 15, 20, 15)
layout.setSpacing(10)
# Progress header
progress_header = QLabel("Sync Progress")
progress_header.setFont(QFont("Arial", 14, QFont.Weight.Bold))
progress_header.setStyleSheet("color: #ffffff;")
# Progress bar
self.progress_bar = QProgressBar()
self.progress_bar.setFixedHeight(8)
self.progress_bar.setStyleSheet("""
QProgressBar {
border: none;
border-radius: 4px;
background: #404040;
}
QProgressBar::chunk {
background: #1db954;
border-radius: 4px;
}
""")
# Progress text
self.progress_text = QLabel("Ready to sync...")
self.progress_text.setFont(QFont("Arial", 11))
self.progress_text.setStyleSheet("color: #b3b3b3;")
# Log area
self.log_area = QTextEdit()
self.log_area.setMinimumHeight(80) # Set minimum height instead of maximum
self.log_area.setStyleSheet("""
QTextEdit {
background: #181818;
border: 1px solid #404040;
border-radius: 4px;
color: #ffffff;
font-size: 10px;
font-family: monospace;
}
""")
self.log_area.setPlainText("Waiting for sync to start...")
layout.addWidget(progress_header)
layout.addWidget(self.progress_bar)
layout.addWidget(self.progress_text)
layout.addWidget(self.log_area, 1) # Allow log area to stretch
return section
def load_playlists_async(self):
"""Start asynchronous playlist loading"""
if self.playlist_loader and self.playlist_loader.isRunning():
return
# Mark as loaded to prevent duplicate auto-loading
self.playlists_loaded = True
# Clear existing playlists
self.clear_playlists()
# Add loading placeholder
loading_label = QLabel("🔄 Loading playlists...")
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
loading_label.setStyleSheet("""
QLabel {
color: #b3b3b3;
font-size: 14px;
padding: 40px;
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
""")
self.playlist_layout.insertWidget(0, loading_label)
# Show loading state
self.refresh_btn.setText("🔄 Loading...")
self.refresh_btn.setEnabled(False)
self.log_area.append("Starting playlist loading...")
# Create and start loader thread
self.playlist_loader = PlaylistLoaderThread(self.spotify_client)
self.playlist_loader.playlist_loaded.connect(self.add_playlist_to_ui)
self.playlist_loader.loading_finished.connect(self.on_loading_finished)
self.playlist_loader.loading_failed.connect(self.on_loading_failed)
self.playlist_loader.progress_updated.connect(self.update_progress)
self.playlist_loader.start()
def add_playlist_to_ui(self, playlist):
"""Add a single playlist to the UI as it's loaded"""
# Simple sync status (placeholder for now)
sync_status = "Never Synced" # TODO: Check actual sync status
item = PlaylistItem(playlist.name, playlist.total_tracks, sync_status, playlist)
item.view_details_clicked.connect(self.show_playlist_details)
# Add subtle fade-in animation
item.setStyleSheet(item.styleSheet() + "background: rgba(40, 40, 40, 0);")
# Insert before the stretch item
self.playlist_layout.insertWidget(self.playlist_layout.count() - 1, item)
self.current_playlists.append(playlist)
# Animate the item appearing
self.animate_item_fade_in(item)
# Update log
self.log_area.append(f"Added playlist: {playlist.name} ({playlist.total_tracks} tracks)")
def animate_item_fade_in(self, item):
"""Add a subtle fade-in animation to playlist items"""
# Start with reduced opacity
item.setStyleSheet("""
PlaylistItem {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
opacity: 0.3;
}
PlaylistItem:hover {
background: #333333;
border: 1px solid #1db954;
}
""")
# Animate to full opacity after a short delay
QTimer.singleShot(50, lambda: item.setStyleSheet("""
PlaylistItem {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
PlaylistItem:hover {
background: #333333;
border: 1px solid #1db954;
}
"""))
def on_loading_finished(self, count):
"""Handle completion of playlist loading"""
# Remove loading placeholder if it exists
for i in range(self.playlist_layout.count()):
item = self.playlist_layout.itemAt(i)
if item and item.widget() and isinstance(item.widget(), QLabel):
if "Loading playlists" in item.widget().text():
item.widget().deleteLater()
break
self.refresh_btn.setText("🔄 Refresh")
self.refresh_btn.setEnabled(True)
self.log_area.append(f"✓ Loaded {count} Spotify playlists successfully")
# Start background preloading of tracks for smaller playlists
self.start_background_preloading()
def start_background_preloading(self):
"""Start background preloading of tracks for smaller playlists"""
if not self.spotify_client:
return
# Preload tracks for playlists with < 100 tracks to improve responsiveness
for playlist in self.current_playlists:
if (playlist.total_tracks < 100 and
playlist.id not in self.track_cache and
not playlist.tracks):
# Create background worker
worker = TrackLoadingWorker(self.spotify_client, playlist.id, playlist.name)
worker.signals.tracks_loaded.connect(self.on_background_tracks_loaded)
# Don't connect error signals for background loading to avoid spam
# Submit with low priority
self.thread_pool.start(worker)
# Add delay between requests to be nice to Spotify API
QTimer.singleShot(2000, lambda: None) # 2 second delay
def on_background_tracks_loaded(self, playlist_id, tracks):
"""Handle background track loading completion"""
# Cache the tracks for future use
self.track_cache[playlist_id] = tracks
# Update the playlist object if we can find it
for playlist in self.current_playlists:
if playlist.id == playlist_id:
playlist.tracks = tracks
break
def on_loading_failed(self, error_msg):
"""Handle playlist loading failure"""
# Remove loading placeholder if it exists
for i in range(self.playlist_layout.count()):
item = self.playlist_layout.itemAt(i)
if item and item.widget() and isinstance(item.widget(), QLabel):
if "Loading playlists" in item.widget().text():
item.widget().deleteLater()
break
self.refresh_btn.setText("🔄 Refresh")
self.refresh_btn.setEnabled(True)
self.log_area.append(f"✗ Failed to load playlists: {error_msg}")
QMessageBox.critical(self, "Error", f"Failed to load playlists: {error_msg}")
def update_progress(self, message):
"""Update progress text"""
self.log_area.append(message)
def disable_refresh_button(self, operation_name="Operation"):
"""Disable refresh button during sync/download operations"""
self.refresh_btn.setEnabled(False)
self.refresh_btn.setText(f"🔄 {operation_name}...")
def enable_refresh_button(self):
"""Re-enable refresh button after operations complete"""
self.refresh_btn.setEnabled(True)
self.refresh_btn.setText("🔄 Refresh")
def load_initial_playlists(self):
"""Load initial playlist data (placeholder or real)"""
if self.spotify_client and self.spotify_client.is_authenticated():
self.refresh_playlists()
else:
# Show placeholder playlists
playlists = [
("Liked Songs", 247, "Synced"),
("Discover Weekly", 30, "Needs Sync"),
("Chill Vibes", 89, "Synced"),
("Workout Mix", 156, "Needs Sync"),
("Road Trip", 67, "Never Synced"),
("Focus Music", 45, "Synced")
]
for name, count, status in playlists:
item = PlaylistItem(name, count, status, None) # No playlist object for placeholders
self.playlist_layout.addWidget(item)
def refresh_playlists(self):
"""Refresh playlists from Spotify API using async loader"""
if not self.spotify_client:
QMessageBox.warning(self, "Error", "Spotify client not available")
return
if not self.spotify_client.is_authenticated():
QMessageBox.warning(self, "Error", "Spotify not authenticated. Please check your settings.")
return
# Use the async loader
self.load_playlists_async()
def show_playlist_details(self, playlist):
"""Show playlist details modal"""
if playlist:
modal = PlaylistDetailsModal(playlist, self)
modal.exec()
def clear_playlists(self):
"""Clear all playlist items from the layout"""
# Clear the current playlists list
self.current_playlists = []
# Remove all items including welcome state
for i in reversed(range(self.playlist_layout.count())):
item = self.playlist_layout.itemAt(i)
if item.widget():
item.widget().deleteLater()
elif item.spacerItem():
continue # Keep the stretch spacer
else:
self.playlist_layout.removeItem(item)
class DownloadMissingTracksModal(QDialog):
"""Enhanced modal for downloading missing tracks with live progress tracking"""
def __init__(self, playlist, parent_page, sync_modal, downloads_page):
print(f"🏗️ Initializing DownloadMissingTracksModal...")
super().__init__(sync_modal) # Set sync modal as parent
self.playlist = playlist
self.parent_page = parent_page
self.sync_modal = sync_modal
self.downloads_page = downloads_page
# State tracking
self.total_tracks = len(playlist.tracks)
self.matched_tracks_count = 0
self.tracks_to_download_count = 0
self.analysis_complete = False
self.download_in_progress = False
print(f"📊 Total tracks: {self.total_tracks}")
# Track analysis results
self.analysis_results = []
self.missing_tracks = []
# Worker tracking
self.active_workers = []
self.fallback_pools = []
print("🎨 Setting up UI...")
self.setup_ui()
print("✅ Modal initialization complete")
def setup_ui(self):
"""Set up the enhanced modal UI"""
self.setWindowTitle(f"Download Missing Tracks - {self.playlist.name}")
self.resize(1200, 900) # Larger size
self.setModal(True)
# Set window flags for proper dialog behavior
self.setWindowFlags(Qt.WindowType.Dialog)
# Improved dark theme styling
self.setStyleSheet("""
QDialog {
background-color: #1e1e1e;
color: #ffffff;
}
QLabel {
color: #ffffff;
}
QPushButton {
background-color: #1db954;
color: #000000;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
padding: 10px 20px;
min-width: 100px;
}
QPushButton:hover {
background-color: #1ed760;
}
QPushButton:disabled {
background-color: #404040;
color: #888888;
}
""")
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(25, 25, 25, 25)
main_layout.setSpacing(15)
# Compact header with dashboard in same row
top_section = self.create_compact_top_section()
main_layout.addWidget(top_section)
# Progress bars section (compact)
progress_section = self.create_progress_section()
main_layout.addWidget(progress_section)
# Track table (main focus - takes most space)
table_section = self.create_track_table()
main_layout.addWidget(table_section, stretch=1) # Give it all available space
# Button controls
button_section = self.create_buttons()
main_layout.addWidget(button_section)
def create_compact_top_section(self):
"""Create compact top section with header and dashboard combined"""
top_frame = QFrame()
top_frame.setStyleSheet("""
QFrame {
background-color: #2d2d2d;
border: 1px solid #444444;
border-radius: 8px;
padding: 15px;
}
""")
layout = QVBoxLayout(top_frame)
layout.setSpacing(15)
# Header row
header_layout = QHBoxLayout()
# Left side - Title and subtitle
title_section = QVBoxLayout()
title_section.setSpacing(2)
title = QLabel("Download Missing Tracks")
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
title.setStyleSheet("color: #1db954;")
subtitle = QLabel(f"Playlist: {self.playlist.name}")
subtitle.setFont(QFont("Arial", 11))
subtitle.setStyleSheet("color: #aaaaaa;")
title_section.addWidget(title)
title_section.addWidget(subtitle)
# Right side - Dashboard counters (horizontal)
dashboard_layout = QHBoxLayout()
dashboard_layout.setSpacing(20)
# Total Tracks
self.total_card = self.create_compact_counter_card("📀 Total", str(self.total_tracks), "#1db954")
# Matched Tracks
self.matched_card = self.create_compact_counter_card("✅ Found", "0", "#4CAF50")
# To Download
self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b")
dashboard_layout.addWidget(self.total_card)
dashboard_layout.addWidget(self.matched_card)
dashboard_layout.addWidget(self.download_card)
dashboard_layout.addStretch()
header_layout.addLayout(title_section)
header_layout.addStretch()
header_layout.addLayout(dashboard_layout)
layout.addLayout(header_layout)
return top_frame
def create_dashboard(self):
"""Create dashboard with live counters"""
dashboard_frame = QFrame()
dashboard_frame.setStyleSheet("""
QFrame {
background-color: #404040;
border: 1px solid #555555;
border-radius: 8px;
padding: 15px;
margin-bottom: 5px;
}
""")
layout = QHBoxLayout(dashboard_frame)
layout.setSpacing(30)
# Total Tracks
self.total_card = self.create_counter_card("📀 Total Tracks", str(self.total_tracks), "#1db954")
# Matched Tracks
self.matched_card = self.create_counter_card("✅ Matched", "0", "#1ed760")
# To Download
self.download_card = self.create_counter_card("⬇️ To Download", "0", "#ff6b6b")
layout.addWidget(self.total_card)
layout.addWidget(self.matched_card)
layout.addWidget(self.download_card)
layout.addStretch()
return dashboard_frame
def create_compact_counter_card(self, title, count, color):
"""Create a compact counter card widget"""
card = QFrame()
card.setStyleSheet(f"""
QFrame {{
background-color: #3a3a3a;
border: 2px solid {color};
border-radius: 6px;
padding: 8px 12px;
min-width: 80px;
}}
""")
layout = QVBoxLayout(card)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
count_label = QLabel(count)
count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
count_label.setStyleSheet(f"color: {color}; background: transparent;")
count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_label = QLabel(title)
title_label.setFont(QFont("Arial", 9))
title_label.setStyleSheet("color: #cccccc; background: transparent;")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(count_label)
layout.addWidget(title_label)
# Store references for updates
if "Total" in title:
self.total_count_label = count_label
elif "Found" in title:
self.matched_count_label = count_label
elif "Missing" in title:
self.download_count_label = count_label
return card
def create_counter_card(self, title, count, color):
"""Create a counter card widget"""
card = QFrame()
card.setStyleSheet(f"""
QFrame {{
background-color: #333333;
border: 2px solid {color};
border-radius: 6px;
padding: 10px;
min-width: 120px;
}}
""")
layout = QVBoxLayout(card)
layout.setSpacing(5)
title_label = QLabel(title)
title_label.setFont(QFont("Arial", 10))
title_label.setStyleSheet("color: #b3b3b3;")
count_label = QLabel(count)
count_label.setFont(QFont("Arial", 20, QFont.Weight.Bold))
count_label.setStyleSheet(f"color: {color};")
count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
layout.addWidget(count_label)
# Store references for updates
if "Total" in title:
self.total_count_label = count_label
elif "Matched" in title:
self.matched_count_label = count_label
elif "Download" in title:
self.download_count_label = count_label
return card
def create_progress_section(self):
"""Create compact dual progress bar section"""
progress_frame = QFrame()
progress_frame.setStyleSheet("""
QFrame {
background-color: #2d2d2d;
border: 1px solid #444444;
border-radius: 8px;
padding: 12px;
}
""")
layout = QVBoxLayout(progress_frame)
layout.setSpacing(8)
# Plex Analysis Progress
analysis_container = QVBoxLayout()
analysis_container.setSpacing(4)
analysis_label = QLabel("🔍 Plex Analysis")
analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
analysis_label.setStyleSheet("color: #cccccc;")
self.analysis_progress = QProgressBar()
self.analysis_progress.setFixedHeight(20)
self.analysis_progress.setStyleSheet("""
QProgressBar {
border: 1px solid #555555;
border-radius: 10px;
text-align: center;
background-color: #444444;
color: #ffffff;
font-size: 11px;
font-weight: bold;
}
QProgressBar::chunk {
background-color: #1db954;
border-radius: 9px;
}
""")
self.analysis_progress.setVisible(False)
analysis_container.addWidget(analysis_label)
analysis_container.addWidget(self.analysis_progress)
# Download Progress
download_container = QVBoxLayout()
download_container.setSpacing(4)
download_label = QLabel("⬇️ Download Progress")
download_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
download_label.setStyleSheet("color: #cccccc;")
self.download_progress = QProgressBar()
self.download_progress.setFixedHeight(20)
self.download_progress.setStyleSheet("""
QProgressBar {
border: 1px solid #555555;
border-radius: 10px;
text-align: center;
background-color: #444444;
color: #ffffff;
font-size: 11px;
font-weight: bold;
}
QProgressBar::chunk {
background-color: #ff6b6b;
border-radius: 9px;
}
""")
self.download_progress.setVisible(False)
download_container.addWidget(download_label)
download_container.addWidget(self.download_progress)
layout.addLayout(analysis_container)
layout.addLayout(download_container)
return progress_frame
def create_track_table(self):
"""Create enhanced track table with Matched/Downloaded columns"""
table_frame = QFrame()
table_frame.setStyleSheet("""
QFrame {
background-color: #2d2d2d;
border: 1px solid #444444;
border-radius: 8px;
padding: 0px;
}
""")
layout = QVBoxLayout(table_frame)
layout.setContentsMargins(15, 15, 15, 15) # Internal padding for spacing
layout.setSpacing(10)
# Table header
header_label = QLabel("📋 Track Analysis")
header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold))
header_label.setStyleSheet("color: #ffffff; padding: 5px;")
# Create custom header row instead of table header
custom_header = self.create_custom_header()
# Create table WITHOUT header
self.track_table = QTableWidget()
self.track_table.setColumnCount(5)
self.track_table.horizontalHeader().setVisible(False) # Hide the problematic header
# Clean table styling (no header needed now)
self.track_table.setStyleSheet("""
QTableWidget {
background-color: #3a3a3a;
alternate-background-color: #424242;
selection-background-color: #1db954;
selection-color: #000000;
gridline-color: #555555;
color: #ffffff;
border: 1px solid #555555;
border-top: none;
font-size: 12px;
}
QTableWidget::item {
padding: 12px 8px;
border-bottom: 1px solid #4a4a4a;
}
QTableWidget::item:selected {
background-color: #1db954;
color: #000000;
}
""")
# Configure column sizes to EXACTLY match custom header
header = self.track_table.horizontalHeader()
# Set fixed columns first to match header exactly
self.track_table.setColumnWidth(2, 90) # Duration - fixed
self.track_table.setColumnWidth(3, 140) # Matched - fixed
# Configure resize modes for proper alignment
# Two stretching columns (Track, Artist) and one last section stretch (Downloaded)
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Track - flexible
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Artist - flexible
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Duration - fixed 90px
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Matched - fixed 140px
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # Downloaded - will be handled by setStretchLastSection
# Let the last section (Downloaded) stretch to fill remaining space
header.setStretchLastSection(True)
# Better table behavior
self.track_table.setAlternatingRowColors(True)
self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.track_table.setShowGrid(True)
self.track_table.setGridStyle(Qt.PenStyle.SolidLine)
# Set row height for better readability
self.track_table.verticalHeader().setDefaultSectionSize(35)
self.track_table.verticalHeader().setVisible(False)
# Populate with initial track data
self.populate_track_table()
layout.addWidget(header_label)
layout.addWidget(custom_header)
layout.addWidget(self.track_table)
return table_frame
def create_custom_header(self):
"""Create a custom header row with visible labels"""
header_frame = QFrame()
header_frame.setStyleSheet("""
QFrame {
background-color: #1db954;
border: 1px solid #169441;
border-radius: 6px;
padding: 0px;
margin: 0px;
}
""")
layout = QHBoxLayout(header_frame)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(1)
# Create header labels with same proportions as table columns
headers = ["Track", "Artist", "Duration", "Matched", "Downloaded"]
# Track - stretch
track_label = QLabel("Track")
track_label.setStyleSheet("""
QLabel {
background-color: transparent;
color: #000000;
font-weight: bold;
font-size: 13px;
padding: 12px 8px;
border-right: 1px solid #169441;
}
""")
track_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Artist - stretch
artist_label = QLabel("Artist")
artist_label.setStyleSheet("""
QLabel {
background-color: transparent;
color: #000000;
font-weight: bold;
font-size: 13px;
padding: 12px 8px;
border-right: 1px solid #169441;
}
""")
artist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Duration - fixed width
duration_label = QLabel("Duration")
duration_label.setFixedWidth(90)
duration_label.setStyleSheet("""
QLabel {
background-color: transparent;
color: #000000;
font-weight: bold;
font-size: 13px;
padding: 12px 8px;
border-right: 1px solid #169441;
}
""")
duration_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Matched - fixed width
matched_label = QLabel("Matched")
matched_label.setFixedWidth(140)
matched_label.setStyleSheet("""
QLabel {
background-color: transparent;
color: #000000;
font-weight: bold;
font-size: 13px;
padding: 12px 8px;
border-right: 1px solid #169441;
}
""")
matched_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Downloaded - stretch to fill remaining space
downloaded_label = QLabel("Downloaded")
downloaded_label.setStyleSheet("""
QLabel {
background-color: transparent;
color: #000000;
font-weight: bold;
font-size: 13px;
padding: 12px 8px;
}
""")
downloaded_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(track_label, stretch=1)
layout.addWidget(artist_label, stretch=1)
layout.addWidget(duration_label)
layout.addWidget(matched_label)
layout.addWidget(downloaded_label, stretch=1) # Let it stretch to fill remaining space
return header_frame
def populate_track_table(self):
"""Populate track table with playlist tracks"""
self.track_table.setRowCount(len(self.playlist.tracks))
for i, track in enumerate(self.playlist.tracks):
# Track name
track_item = QTableWidgetItem(track.name)
track_item.setFlags(track_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(i, 0, track_item)
# Artist
artist_name = track.artists[0] if track.artists else "Unknown Artist"
artist_item = QTableWidgetItem(artist_name)
artist_item.setFlags(artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.track_table.setItem(i, 1, artist_item)
# Duration
duration = self.format_duration(track.duration_ms)
duration_item = QTableWidgetItem(duration)
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(i, 2, duration_item)
# Matched status (initially pending)
matched_item = QTableWidgetItem("⏳ Pending")
matched_item.setFlags(matched_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(i, 3, matched_item)
# Downloaded status (initially pending)
downloaded_item = QTableWidgetItem("⏳ Pending")
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(i, 4, downloaded_item)
def format_duration(self, duration_ms):
"""Convert milliseconds to MM:SS format"""
seconds = duration_ms // 1000
minutes = seconds // 60
seconds = seconds % 60
return f"{minutes}:{seconds:02d}"
def create_buttons(self):
"""Create improved button section"""
button_frame = QFrame()
button_frame.setStyleSheet("""
QFrame {
background-color: transparent;
padding: 10px;
}
""")
layout = QHBoxLayout(button_frame)
layout.setSpacing(15)
layout.setContentsMargins(0, 10, 0, 0)
# Begin Search button
self.begin_search_btn = QPushButton("Begin Search")
self.begin_search_btn.setFixedSize(160, 40)
self.begin_search_btn.clicked.connect(self.on_begin_search_clicked)
# Cancel button
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.setFixedSize(110, 40)
self.cancel_btn.setStyleSheet("""
QPushButton {
background-color: #d32f2f;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
padding: 10px 20px;
}
QPushButton:hover {
background-color: #f44336;
}
QPushButton:pressed {
background-color: #b71c1c;
}
""")
self.cancel_btn.clicked.connect(self.on_cancel_clicked)
self.cancel_btn.hide() # Initially hidden
# Close button
self.close_btn = QPushButton("Close")
self.close_btn.setFixedSize(110, 40)
self.close_btn.setStyleSheet("""
QPushButton {
background-color: #616161;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
padding: 10px 20px;
}
QPushButton:hover {
background-color: #757575;
}
QPushButton:pressed {
background-color: #424242;
}
""")
self.close_btn.clicked.connect(self.on_close_clicked)
layout.addStretch()
layout.addWidget(self.begin_search_btn)
layout.addWidget(self.cancel_btn)
layout.addWidget(self.close_btn)
return button_frame
def on_begin_search_clicked(self):
"""Handle Begin Search button click - starts Plex analysis"""
# 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...")
# Disable refresh button during operations
if hasattr(self.parent_page, 'disable_refresh_button'):
self.parent_page.disable_refresh_button("Analyzing")
# Start Plex analysis
self.start_plex_analysis()
def start_plex_analysis(self):
"""Start Plex analysis using existing worker"""
plex_client = getattr(self.parent_page, 'plex_client', None)
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, plex_client)
# Connect signals for live updates
worker.signals.analysis_started.connect(self.on_analysis_started)
worker.signals.track_analyzed.connect(self.on_track_analyzed)
worker.signals.analysis_completed.connect(self.on_analysis_completed)
worker.signals.analysis_failed.connect(self.on_analysis_failed)
# Track worker for cleanup
self.active_workers.append(worker)
# Submit to thread pool
if hasattr(self.parent_page, 'thread_pool'):
self.parent_page.thread_pool.start(worker)
else:
# Create fallback thread pool
thread_pool = QThreadPool()
self.fallback_pools.append(thread_pool)
thread_pool.start(worker)
def on_analysis_started(self, total_tracks):
"""Handle analysis start"""
print(f"🔍 Analysis started for {total_tracks} tracks")
# Update main console log
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f"🔍 Starting Plex analysis for {total_tracks} tracks...")
def on_track_analyzed(self, track_index, result):
"""Handle individual track analysis completion with live UI updates"""
# Update progress bar
self.analysis_progress.setValue(track_index)
# Update counters and table
if result.exists_in_plex:
# Track found 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:
# Track missing from Plex - will need download
matched_text = "❌ Missing"
self.tracks_to_download_count += 1
self.download_count_label.setText(str(self.tracks_to_download_count))
# Update table row
matched_item = QTableWidgetItem(matched_text)
matched_item.setFlags(matched_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(track_index - 1, 3, matched_item)
# Update main console log every 10 tracks or significant findings
if hasattr(self.parent_page, 'log_area'):
track_name = result.spotify_track.name
artist_name = result.spotify_track.artists[0] if result.spotify_track.artists else "Unknown"
# Log every 10 tracks
if track_index % 10 == 0:
progress_pct = (track_index / len(self.playlist.tracks)) * 100
self.parent_page.log_area.append(f"⏳ Analyzed {track_index}/{len(self.playlist.tracks)} tracks ({progress_pct:.0f}%) - {self.matched_tracks_count} found, {self.tracks_to_download_count} missing")
# Log specific track info for missing tracks
elif not result.exists_in_plex:
self.parent_page.log_area.append(f"❌ Missing: {track_name} by {artist_name}")
# Log high confidence matches occasionally
elif result.confidence >= 0.95 and track_index % 20 == 0:
self.parent_page.log_area.append(f"✅ High confidence match: {track_name} ({result.confidence:.1f})")
# 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):
"""Handle analysis completion"""
self.analysis_complete = True
self.analysis_results = results
self.missing_tracks = [r for r in results if not r.exists_in_plex]
print(f"✅ Analysis complete: {len(self.missing_tracks)} to download, {self.matched_tracks_count} matched")
# Update main console log with analysis summary
if hasattr(self.parent_page, 'log_area'):
total_tracks = len(results)
matched_count = len([r for r in results if r.exists_in_plex])
missing_count = len(self.missing_tracks)
self.parent_page.log_area.append(f"✅ Plex analysis complete: {matched_count}/{total_tracks} tracks found in library")
if missing_count > 0:
self.parent_page.log_area.append(f"⏬ Preparing to download {missing_count} missing tracks...")
else:
self.parent_page.log_area.append(f"🎉 All tracks already exist in Plex - no downloads needed!")
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)
# Update refresh button text for download phase
if hasattr(self.parent_page, 'disable_refresh_button'):
self.parent_page.disable_refresh_button("Downloading")
# 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()
# Re-enable refresh button - operations complete
if hasattr(self.parent_page, 'enable_refresh_button'):
self.parent_page.enable_refresh_button()
QMessageBox.information(self, "Analysis Complete",
"All tracks already exist in Plex library!\nNo downloads needed.")
def on_analysis_failed(self, error_message):
"""Handle analysis failure"""
print(f"❌ Analysis failed: {error_message}")
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
# 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)
# Re-enable refresh button - operation failed
if hasattr(self.parent_page, 'enable_refresh_button'):
self.parent_page.enable_refresh_button()
def start_download_progress(self):
"""Start actual download progress tracking"""
print(f"🚀 Starting download progress for {len(self.missing_tracks)} tracks")
# Update main console log
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f"🚀 Starting Soulseek downloads for {len(self.missing_tracks)} missing tracks...")
# Show download progress bar
self.download_progress.setVisible(True)
self.download_progress.setMaximum(len(self.missing_tracks))
self.download_progress.setValue(0)
# Start real downloads
self.start_soulseek_downloads()
def start_soulseek_downloads(self):
"""Start real Soulseek downloads for missing tracks using existing downloads.py infrastructure"""
if not self.missing_tracks:
return
# Check downloads page availability
if not self.downloads_page:
QMessageBox.critical(self, "Downloads Page Unavailable",
"Downloads page not available. Please restart the application.")
return
# Start download process
self.download_in_progress = True
self.current_download = 0
# Start downloading tracks using downloads.py infrastructure
self.download_missing_tracks_with_infrastructure()
def download_missing_tracks_with_infrastructure(self):
"""Download missing tracks using existing matched download infrastructure"""
if not self.missing_tracks:
self.on_all_downloads_complete()
return
print(f"🚀 Starting download of {len(self.missing_tracks)} missing tracks using downloads.py infrastructure...")
# Process each missing track
self.successful_downloads = 0
self.failed_downloads = 0
self.completed_downloads = 0
self.current_search_index = 0
# Start searching tracks sequentially to avoid overwhelming the system
# (searches can take up to 25 seconds each)
self.start_next_track_search()
def start_next_track_search(self):
"""Start searching for the next track in sequence"""
# Check for cancellation
if hasattr(self, 'cancel_requested') and self.cancel_requested:
print("🛑 Search cancelled by user")
return
if self.current_search_index >= len(self.missing_tracks):
# All searches complete - check if we should finish
if self.completed_downloads >= len(self.missing_tracks):
self.on_all_downloads_complete()
return
track_result = self.missing_tracks[self.current_search_index]
self.start_single_track_download(track_result, self.current_search_index)
def start_single_track_download(self, track_result, track_index):
"""Start download for a single track using smart search strategy"""
track = track_result.spotify_track
artist_name = track.artists[0] if track.artists else "Unknown Artist"
track_name = track.name
print(f"🎵 Starting search {track_index + 1}/{len(self.missing_tracks)}: {track_name} by {artist_name}")
# Update main console log
if hasattr(self.parent_page, 'log_area'):
progress_pct = ((track_index + 1) / len(self.missing_tracks)) * 100
self.parent_page.log_area.append(f"🔍 Searching ({track_index + 1}/{len(self.missing_tracks)}, {progress_pct:.0f}%): {track_name} by {artist_name}")
# Update table to show searching status
table_index = self.find_track_index(track)
if table_index is not None:
searching_item = QTableWidgetItem("🔍 Searching")
searching_item.setFlags(searching_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
searching_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(table_index, 4, searching_item)
# Use smart search strategy to find best match
self.smart_search_and_download(track, track_index, table_index)
def smart_search_and_download(self, spotify_track, track_index, table_index):
"""Implement smart search strategy with multiple query variations"""
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
track_name = spotify_track.name
duration_ms = getattr(spotify_track, 'duration_ms', 0)
# Generate multiple search queries in order of preference
search_queries = self.generate_smart_search_queries(artist_name, track_name)
print(f"🔍 Generated {len(search_queries)} search queries for: {track_name} by {artist_name}")
# Try each search query until we find good results
self.try_search_queries(search_queries, spotify_track, track_index, table_index, 0)
def generate_smart_search_queries(self, artist_name, track_name):
"""Generate multiple search query variations with special handling for single-word tracks"""
queries = []
# Check if track name is a single word (no spaces)
is_single_word = len(track_name.strip().split()) == 1
if is_single_word:
# For single-word tracks (like "Aether"), always include artist to be more specific
print(f"🎯 Single-word track detected: '{track_name}' - including artist name for specificity")
# Strategy 1: Track + full artist name (most specific)
if artist_name:
queries.append(f"{track_name} {artist_name}".strip())
# Strategy 2: Track + shortened artist (remove common articles and prefixes)
shortened_artist = self.shorten_artist_name(artist_name)
if shortened_artist and shortened_artist != artist_name:
queries.append(f"{track_name} {shortened_artist}".strip())
# Strategy 3: Track + first word of artist (for multi-word artists)
first_word = artist_name.split()[0] if artist_name else ""
if first_word and len(first_word) > 2:
queries.append(f"{track_name} {first_word}".strip())
# Strategy 4: Track name only (fallback for single words)
queries.append(track_name.strip())
else:
# For multi-word tracks, use original logic (track name only often works well)
# Strategy 1: Track name only (often most effective for popular multi-word tracks)
queries.append(track_name.strip())
# Strategy 2: Track name + shortened artist (remove common articles and prefixes)
shortened_artist = self.shorten_artist_name(artist_name)
if shortened_artist and shortened_artist != artist_name:
queries.append(f"{track_name} {shortened_artist}".strip())
# Strategy 3: Track name + first word of artist (for multi-word artists)
first_word = artist_name.split()[0] if artist_name else ""
if first_word and len(first_word) > 2:
queries.append(f"{track_name} {first_word}".strip())
# Strategy 4: Track name + full artist name (traditional approach)
if artist_name:
queries.append(f"{track_name} {artist_name}".strip())
# Remove duplicates while preserving order
unique_queries = []
for query in queries:
if query and query not in unique_queries:
unique_queries.append(query)
return unique_queries
def shorten_artist_name(self, artist_name):
"""Remove common articles and prefixes that cause search issues"""
if not artist_name:
return artist_name
# Common prefixes that cause search problems
prefixes_to_remove = [
"The ", "A ", "An ",
"DJ ", "MC ", "Lil ", "Big ",
"Young ", "Old ", "Saint ", "St. "
]
shortened = artist_name
for prefix in prefixes_to_remove:
if shortened.startswith(prefix):
shortened = shortened[len(prefix):]
break
# Remove common suffixes
suffixes_to_remove = [" Jr.", " Sr.", " Jr", " Sr", " III", " II"]
for suffix in suffixes_to_remove:
if shortened.endswith(suffix):
shortened = shortened[:-len(suffix)]
break
return shortened.strip()
def generate_word_removal_fallbacks(self, original_query, spotify_track):
"""Generate fallback queries by removing words when original query returns 0 results"""
fallback_queries = []
# Parse the original query to understand its components
track_name = spotify_track.name.strip()
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
print(f"🔄 Generating fallbacks for: '{original_query}' (track: '{track_name}', artist: '{artist_name}')")
# If the query contains the artist name, try removing words from artist
if artist_name and artist_name.lower() in original_query.lower():
# Remove common words from artist name
artist_words = artist_name.split()
if len(artist_words) > 1:
# Try removing first word if it's a common article/prefix
if artist_words[0].lower() in ['the', 'a', 'an', 'dj', 'mc', 'lil', 'big', 'young', 'old', 'saint', 'st.', 'dr.', 'mr.', 'ms.']:
reduced_artist = ' '.join(artist_words[1:])
fallback_query = f"{track_name} {reduced_artist}".strip()
if fallback_query != original_query and fallback_query not in fallback_queries:
fallback_queries.append(fallback_query)
print(f" 💡 Fallback 1: '{fallback_query}' (removed article/prefix)")
# Try removing last word (often suffixes like Jr., Sr., etc.)
if artist_words[-1].lower() in ['jr.', 'sr.', 'jr', 'sr', 'iii', 'ii', 'iv', 'v']:
reduced_artist = ' '.join(artist_words[:-1])
fallback_query = f"{track_name} {reduced_artist}".strip()
if fallback_query != original_query and fallback_query not in fallback_queries:
fallback_queries.append(fallback_query)
print(f" 💡 Fallback 2: '{fallback_query}' (removed suffix)")
# Try just first word of artist if multi-word
if len(artist_words) >= 2:
first_word = artist_words[0]
if len(first_word) > 2 and first_word.lower() not in ['the', 'a', 'an']:
fallback_query = f"{track_name} {first_word}".strip()
if fallback_query != original_query and fallback_query not in fallback_queries:
fallback_queries.append(fallback_query)
print(f" 💡 Fallback 3: '{fallback_query}' (first word only)")
# Try just last word of artist if it's likely the main name
if len(artist_words) >= 2:
last_word = artist_words[-1]
if len(last_word) > 2 and last_word.lower() not in ['jr.', 'sr.', 'jr', 'sr', 'iii', 'ii']:
fallback_query = f"{track_name} {last_word}".strip()
if fallback_query != original_query and fallback_query not in fallback_queries:
fallback_queries.append(fallback_query)
print(f" 💡 Fallback 4: '{fallback_query}' (last word only)")
# Try just track name if not already tried
if track_name.strip() != original_query and track_name.strip() not in fallback_queries:
fallback_queries.append(track_name.strip())
print(f" 💡 Fallback 5: '{track_name.strip()}' (track only)")
# Try removing special characters and punctuation from track name
import re
clean_track = re.sub(r'[^\w\s]', '', track_name).strip()
if clean_track != track_name and clean_track not in fallback_queries:
fallback_queries.append(clean_track)
print(f" 💡 Fallback 6: '{clean_track}' (cleaned track)")
# If track has multiple words, try just the first word
track_words = track_name.split()
if len(track_words) > 1:
first_track_word = track_words[0]
if len(first_track_word) > 2 and first_track_word not in fallback_queries:
fallback_queries.append(first_track_word)
print(f" 💡 Fallback 7: '{first_track_word}' (first track word)")
print(f"🔄 Generated {len(fallback_queries)} fallback queries")
return fallback_queries
def try_search_queries(self, queries, spotify_track, track_index, table_index, query_index):
"""Try search queries sequentially until we find good results"""
# Check for cancellation
if hasattr(self, 'cancel_requested') and self.cancel_requested:
print("🛑 Search queries cancelled by user")
return
if query_index >= len(queries):
# All queries failed
self.on_search_failed(spotify_track, track_index, table_index)
return
current_query = queries[query_index]
print(f"🔍 Trying search query {query_index + 1}/{len(queries)}: '{current_query}'")
# Update console with current search attempt
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f" 🔍 Query {query_index + 1}: '{current_query}'")
# Start search using downloads.py SearchThread
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
class SmartSearchWorkerSignals(QObject):
search_completed = pyqtSignal(list, int, str) # results, query_index, query
search_failed = pyqtSignal(int, str, str) # query_index, query, error
class SmartSearchWorker(QRunnable):
def __init__(self, soulseek_client, query, query_index):
super().__init__()
self.soulseek_client = soulseek_client
self.query = query
self.query_index = query_index
self.signals = SmartSearchWorkerSignals()
self._stop_requested = False
def run(self):
loop = None
try:
import asyncio
# Create a completely fresh event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Perform search with proper await
results = loop.run_until_complete(self._do_search())
if not self._stop_requested:
# Process results into the format expected by our system
processed_results = self._process_search_results(results)
self.signals.search_completed.emit(processed_results, self.query_index, self.query)
except Exception as e:
if not self._stop_requested:
self.signals.search_failed.emit(self.query_index, self.query, str(e))
finally:
# Ensure proper cleanup
if loop:
try:
# Close any remaining tasks
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
except Exception as e:
print(f"Error cleaning up event loop: {e}")
async def _do_search(self):
"""Perform the actual search with proper await"""
return await self.soulseek_client.search(self.query)
def _process_search_results(self, raw_results):
"""Process raw search results into the format we need"""
if not raw_results:
return []
# The search returns a tuple (tracks, albums)
processed_results = []
if isinstance(raw_results, tuple) and len(raw_results) == 2:
tracks, albums = raw_results
# Add individual tracks
if tracks:
processed_results.extend(tracks)
# Add tracks from albums (since we're looking for individual tracks)
if albums:
for album in albums:
if hasattr(album, 'tracks') and album.tracks:
processed_results.extend(album.tracks)
print(f"🔍 Processed {len(processed_results)} track results from search")
return processed_results
# Create and start search worker
worker = SmartSearchWorker(self.parent_page.soulseek_client, current_query, query_index)
worker.signals.search_completed.connect(
lambda results, qi, query: self.on_search_query_completed(
results, queries, spotify_track, track_index, table_index, qi, query
)
)
worker.signals.search_failed.connect(
lambda qi, query, error: self.on_search_query_failed(
queries, spotify_track, track_index, table_index, qi, query, error
)
)
# Submit to thread pool
if hasattr(self.parent_page, 'thread_pool'):
self.parent_page.thread_pool.start(worker)
else:
thread_pool = QThreadPool()
self.fallback_pools.append(thread_pool)
thread_pool.start(worker)
def on_search_query_completed(self, results, queries, spotify_track, track_index, table_index, query_index, query):
"""Handle completion of a search query"""
print(f"✅ Search query '{query}' returned {len(results)} results")
# Update console with result count
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f" ✅ Found {len(results)} tracks for '{query}'")
if results and len(results) > 0:
# Found results - collect all valid candidates (not just best match)
valid_candidates = self.get_valid_candidates(results, spotify_track, query)
if valid_candidates:
print(f"🎯 Found {len(valid_candidates)} valid candidates")
# Store all candidates for potential retry
if not hasattr(self, 'track_search_results'):
self.track_search_results = {}
self.track_search_results[track_index] = valid_candidates
# Reset candidate index for this track
if not hasattr(self, 'track_candidate_index'):
self.track_candidate_index = {}
self.track_candidate_index[track_index] = 0
# Start with the best candidate
best_match = valid_candidates[0]
print(f"🎯 Selected best match (1/{len(valid_candidates)}): {best_match.filename}")
# Update console with selection
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f" 🎯 Best match (1/{len(valid_candidates)}): {best_match.filename}")
# Start download with the best match
self.start_download_with_match(best_match, spotify_track, track_index, table_index)
return
# Check if this query returned zero results and try word removal fallback
if len(results) == 0:
print(f"⚠️ Query '{query}' returned 0 results, trying word removal fallback...")
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f" ⚠️ 0 results for '{query}', trying word removal...")
# Generate fallback queries with word removal
fallback_queries = self.generate_word_removal_fallbacks(query, spotify_track)
if fallback_queries:
print(f"🔄 Generated {len(fallback_queries)} fallback queries")
# Insert fallback queries into the remaining query list
remaining_queries = queries[query_index + 1:]
new_queries = queries[:query_index + 1] + fallback_queries + remaining_queries
# Try the first fallback
self.try_search_queries(new_queries, spotify_track, track_index, table_index, query_index + 1)
return
# No good results, try next query
print(f"⚠️ Query '{query}' had no suitable matches, trying next...")
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f" ⚠️ No suitable matches for '{query}', trying next strategy...")
self.try_search_queries(queries, spotify_track, track_index, table_index, query_index + 1)
def on_search_query_failed(self, queries, spotify_track, track_index, table_index, query_index, query, error):
"""Handle search query failure"""
print(f"❌ Search query '{query}' failed: {error}")
# Try next query
self.try_search_queries(queries, spotify_track, track_index, table_index, query_index + 1)
def select_best_match(self, results, spotify_track, query):
"""Select the best match from search results using strict matching criteria"""
if not results:
return None
# Get Spotify track metadata for comparison
track_name = spotify_track.name.lower().strip()
artist_name = spotify_track.artists[0].lower().strip() if spotify_track.artists else ""
duration_ms = getattr(spotify_track, 'duration_ms', 0)
target_duration_seconds = duration_ms / 1000 if duration_ms > 0 else None
print(f"🎯 Matching against: '{track_name}' by '{artist_name}' ({target_duration_seconds}s)")
# Score each result
scored_results = []
for result in results:
score = 0
reasons = [] # For debugging
# Get result metadata with proper null handling
result_title = ""
if hasattr(result, 'title') and result.title:
result_title = str(result.title).lower().strip()
result_artist = ""
if hasattr(result, 'artist') and result.artist:
result_artist = str(result.artist).lower().strip()
result_filename = ""
if hasattr(result, 'filename') and result.filename:
result_filename = str(result.filename).lower()
result_duration = 0
if hasattr(result, 'duration') and result.duration:
result_duration = result.duration
# INTELLIGENT TRACK MATCHING: Use advanced matching with confidence scoring
match_result = self.intelligent_track_match(track_name, result_title, result_filename)
# Only proceed if we have a reasonable match
if not match_result['matched'] or match_result['confidence'] < 60:
continue # Skip this result entirely
# Score based on match confidence and type
base_score = match_result['confidence']
match_type = match_result['type']
if match_type == 'exact_title':
score += 150 # Highest priority for exact title match
reasons.append(f"track_exact_title({match_result['confidence']}%)")
elif match_type == 'exact_filename':
score += 140 # Very high for exact filename match
reasons.append(f"track_exact_filename({match_result['confidence']}%)")
elif match_type == 'substring_title':
score += 130 # High for substring in title
reasons.append(f"track_substring_title({match_result['confidence']}%)")
elif match_type == 'substring_filename':
score += 120 # High for substring in filename
reasons.append(f"track_substring_filename({match_result['confidence']}%)")
elif match_type == 'word_match_high':
score += 110 # Good for high word match
reasons.append(f"track_word_match_high({match_result['confidence']}%)")
elif match_type == 'word_match_medium':
score += 100 # Medium for medium word match
reasons.append(f"track_word_match_medium({match_result['confidence']}%)")
elif match_type == 'fuzzy_match':
score += 90 # Lower for fuzzy match
reasons.append(f"track_fuzzy_match({match_result['confidence']}%)")
else:
score += base_score # Use confidence as base score
reasons.append(f"track_match_{match_type}({match_result['confidence']}%)")
# BONUS: Artist name contained (extra points)
artist_contained = False
# Check if artist name is contained in the result artist field
if artist_name and artist_name in result_artist:
score += 80
reasons.append("artist_exact_in_artist")
artist_contained = True
# Check if artist name is contained in the filename
elif artist_name and artist_name in result_filename:
score += 60
reasons.append("artist_exact_in_filename")
artist_contained = True
# Check if artist name is contained in the title
elif artist_name and artist_name in result_title:
score += 40
reasons.append("artist_exact_in_title")
artist_contained = True
# CRITICAL: Duration matching (highest priority for accuracy)
if target_duration_seconds and result_duration > 0:
duration_diff = abs(result_duration - target_duration_seconds)
if duration_diff <= 2: # Within 2 seconds - almost certainly correct
score += 100
reasons.append(f"duration_perfect({duration_diff:.1f}s)")
elif duration_diff <= 5: # Within 5 seconds - very likely correct
score += 60
reasons.append(f"duration_very_good({duration_diff:.1f}s)")
elif duration_diff <= 10: # Within 10 seconds - likely correct
score += 30
reasons.append(f"duration_good({duration_diff:.1f}s)")
elif duration_diff <= 30: # Within 30 seconds - possibly correct
score += 10
reasons.append(f"duration_fair({duration_diff:.1f}s)")
else:
# Penalty for very different duration
score -= 20
reasons.append(f"duration_mismatch({duration_diff:.1f}s)")
# ENHANCED QUALITY PREFERENCE: Heavily prioritize FLAC and high quality
quality_score = self.calculate_quality_score(result, result_filename)
score += quality_score['score']
if quality_score['reason']:
reasons.append(quality_score['reason'])
# File size reasonableness (avoid tiny or corrupted files)
if hasattr(result, 'size') and result.size:
if result.size > 5000000: # > 5MB (reasonable for music)
score += 5
reasons.append("size_reasonable")
elif result.size < 1000000: # < 1MB (suspicious)
score -= 10
reasons.append("size_suspicious")
# Penalize obvious mismatches in filename
if 'remix' in result_filename and 'remix' not in track_name.lower():
score -= 30
reasons.append("unwanted_remix")
if 'live' in result_filename and 'live' not in track_name.lower():
score -= 20
reasons.append("unwanted_live")
if 'instrumental' in result_filename and 'instrumental' not in track_name.lower():
score -= 25
reasons.append("unwanted_instrumental")
scored_results.append((score, result, reasons))
# Sort by score (highest first)
scored_results.sort(key=lambda x: x[0], reverse=True)
if scored_results:
best_score, best_result, best_reasons = scored_results[0]
print(f"🏆 Best match score: {best_score} - {' + '.join(best_reasons)}")
print(f" File: {best_result.filename}")
# Only return result if score is reasonable (avoid terrible matches)
# Since we now require exact track name containment (120-150 points minimum),
# we can set a higher threshold
if best_score >= 120: # Require minimum quality with strict matching
return best_result
else:
print(f"⚠️ Best score ({best_score}) too low, rejecting all matches")
return None
return None
def get_valid_candidates(self, results, spotify_track, query):
"""Get all valid candidates sorted by score (for retry mechanism)"""
if not results:
return []
# Get Spotify track metadata for comparison
track_name = spotify_track.name.lower().strip()
artist_name = spotify_track.artists[0].lower().strip() if spotify_track.artists else ""
duration_ms = getattr(spotify_track, 'duration_ms', 0)
target_duration_seconds = duration_ms / 1000 if duration_ms > 0 else None
# Score each result
scored_results = []
for result in results:
score = 0
reasons = []
# Get result metadata with proper null handling
result_title = ""
if hasattr(result, 'title') and result.title:
result_title = str(result.title).lower().strip()
result_artist = ""
if hasattr(result, 'artist') and result.artist:
result_artist = str(result.artist).lower().strip()
result_filename = ""
if hasattr(result, 'filename') and result.filename:
result_filename = str(result.filename).lower()
result_duration = 0
if hasattr(result, 'duration') and result.duration:
result_duration = result.duration
# Use the same intelligent matching as select_best_match
match_result = self.intelligent_track_match(track_name, result_title, result_filename)
# Only include candidates that meet minimum match criteria
if not match_result['matched'] or match_result['confidence'] < 60:
continue # Skip this result
# Calculate full score using the same logic as select_best_match
score = self._calculate_candidate_score(match_result, artist_name, result_artist, result_filename,
target_duration_seconds, result_duration, result, reasons)
if score >= 120: # Same minimum threshold as select_best_match
scored_results.append((score, result, reasons))
# Sort by score (highest first)
scored_results.sort(key=lambda x: x[0], reverse=True)
# Return just the result objects, sorted by quality
candidates = [result for score, result, reasons in scored_results]
print(f"🔍 Valid candidates found: {len(candidates)} (from {len(results)} total results)")
for i, (score, result, reasons) in enumerate(scored_results[:5]): # Show top 5
print(f" {i+1}. Score: {score} - {result.filename} - {' + '.join(reasons[:3])}")
return candidates
def _calculate_candidate_score(self, match_result, artist_name, result_artist, result_filename,
target_duration_seconds, result_duration, result, reasons):
"""Calculate full score for a candidate (extracted from select_best_match logic)"""
score = 0
# Track matching score
match_type = match_result['type']
if match_type == 'exact_title':
score += 150
reasons.append(f"track_exact_title({match_result['confidence']}%)")
elif match_type == 'exact_filename':
score += 140
reasons.append(f"track_exact_filename({match_result['confidence']}%)")
elif match_type == 'substring_title':
score += 130
reasons.append(f"track_substring_title({match_result['confidence']}%)")
elif match_type == 'substring_filename':
score += 120
reasons.append(f"track_substring_filename({match_result['confidence']}%)")
elif match_type == 'word_match_high':
score += 110
reasons.append(f"track_word_match_high({match_result['confidence']}%)")
elif match_type == 'word_match_medium':
score += 100
reasons.append(f"track_word_match_medium({match_result['confidence']}%)")
elif match_type == 'fuzzy_match':
score += 90
reasons.append(f"track_fuzzy_match({match_result['confidence']}%)")
# Artist matching
if artist_name and artist_name in result_artist:
score += 80
reasons.append("artist_exact_in_artist")
elif artist_name and artist_name in result_filename:
score += 60
reasons.append("artist_exact_in_filename")
# Duration matching
if target_duration_seconds and result_duration > 0:
duration_diff = abs(result_duration - target_duration_seconds)
if duration_diff <= 2:
score += 100
reasons.append(f"duration_perfect({duration_diff:.1f}s)")
elif duration_diff <= 5:
score += 60
reasons.append(f"duration_very_good({duration_diff:.1f}s)")
elif duration_diff <= 10:
score += 30
reasons.append(f"duration_good({duration_diff:.1f}s)")
# Quality scoring
quality_score = self.calculate_quality_score(result, result_filename)
score += quality_score['score']
if quality_score['reason']:
reasons.append(quality_score['reason'])
return score
def calculate_string_similarity(self, str1, str2):
"""Calculate similarity between two strings (0.0 to 1.0)"""
if not str1 or not str2:
return 0.0
# Normalize strings
str1 = str1.lower().strip()
str2 = str2.lower().strip()
# Exact match
if str1 == str2:
return 1.0
# Simple similarity calculation
# Count matching words
words1 = set(str1.split())
words2 = set(str2.split())
if not words1 or not words2:
return 0.0
intersection = words1.intersection(words2)
union = words1.union(words2)
return len(intersection) / len(union) if union else 0.0
def calculate_quality_score(self, result, filename):
"""Calculate enhanced quality score prioritizing FLAC and high bitrates"""
score = 0
reason = ""
# Get file format from multiple sources
file_format = ""
bitrate = 0
# Check quality field first
if hasattr(result, 'quality') and result.quality:
quality_lower = result.quality.lower()
file_format = quality_lower
# Also check filename for format clues
filename_lower = filename.lower() if filename else ""
# Extract format from filename if not found in quality field
if not file_format:
if '.flac' in filename_lower:
file_format = 'flac'
elif '.alac' in filename_lower or '.m4a' in filename_lower:
file_format = 'alac'
elif '.ape' in filename_lower:
file_format = 'ape'
elif '.mp3' in filename_lower:
file_format = 'mp3'
elif '.aac' in filename_lower:
file_format = 'aac'
elif '.ogg' in filename_lower or '.oga' in filename_lower:
file_format = 'ogg'
# Extract bitrate from filename (common patterns)
import re
bitrate_match = re.search(r'(\d{2,4})\s*k?bps?', filename_lower)
if not bitrate_match:
bitrate_match = re.search(r'\[(\d{2,4})k?\]', filename_lower)
if not bitrate_match:
bitrate_match = re.search(r'(\d{2,4})k(?![a-z])', filename_lower) # 320k but not 320kb
if bitrate_match:
try:
bitrate = int(bitrate_match.group(1))
except:
bitrate = 0
# PRIORITY 1: FLAC gets highest bonus (user requirement)
if file_format == 'flac' or 'flac' in filename_lower:
score += 50 # Significantly higher than old +15
reason = f"format_flac_priority"
# Extra bonus for high quality FLAC indicators
if any(indicator in filename_lower for indicator in ['24bit', '24-bit', '96khz', '192khz', 'hi-res']):
score += 20
reason += "_hires"
# PRIORITY 2: Other lossless formats
elif file_format in ['alac', 'ape']:
score += 35
reason = f"format_lossless_{file_format}"
# PRIORITY 3: High bitrate MP3/AAC (320kbps)
elif file_format in ['mp3', 'aac']:
if bitrate >= 320:
score += 25
reason = f"format_mp3_320kbps"
elif bitrate >= 256:
score += 15
reason = f"format_mp3_256kbps"
elif bitrate >= 192:
score += 10
reason = f"format_mp3_192kbps"
elif bitrate >= 128:
score += 5
reason = f"format_mp3_128kbps"
else:
score += 5 # Unknown bitrate MP3
reason = f"format_mp3_unknown"
# PRIORITY 4: Other formats
elif file_format == 'ogg':
score += 8
reason = "format_ogg"
else:
# Unknown format - give minimal points
score += 2
reason = "format_unknown"
# BONUS: Clean filename (no brackets, underscores, or messy formatting)
clean_filename_score = 0
if filename_lower:
# Penalty for messy filenames
underscore_count = filename_lower.count('_')
if underscore_count > 3: # Too many underscores
clean_filename_score -= 5
elif '[' in filename_lower and ']' in filename_lower:
# Some brackets are OK (like [FLAC]) but too many is messy
bracket_count = filename_lower.count('[') + filename_lower.count(']')
if bracket_count > 4:
clean_filename_score -= 3
# Bonus for clean formatting
if not any(char in filename_lower for char in ['_', '@', '#', '$', '%']):
clean_filename_score += 10
if reason:
reason += "_clean"
score += clean_filename_score
# BONUS: Album context detection
if any(indicator in filename_lower for indicator in ['album', 'discography', 'collection']):
score += 5
if reason:
reason += "_album_context"
return {'score': score, 'reason': reason}
def intelligent_track_match(self, spotify_track_name, result_title, result_filename):
"""Intelligent track matching that handles various formatting patterns"""
import re
# Normalize the Spotify track name
clean_spotify_name = self.normalize_track_title(spotify_track_name)
# Create multiple versions of the result title/filename for matching
result_text = f"{result_title} {result_filename}".lower()
clean_result_title = self.normalize_track_title(result_title)
clean_result_filename = self.normalize_track_title(result_filename)
# Matching strategies with different confidence levels
match_types = []
# EXACT MATCH (highest confidence)
if clean_spotify_name == clean_result_title:
match_types.append(('exact_title', 100))
elif clean_spotify_name == clean_result_filename:
match_types.append(('exact_filename', 95))
# SUBSTRING MATCH (high confidence)
elif clean_spotify_name in clean_result_title:
match_types.append(('substring_title', 90))
elif clean_spotify_name in clean_result_filename:
match_types.append(('substring_filename', 85))
# WORD MATCH (medium confidence)
# Check if all important words from track name appear in result
spotify_words = set(clean_spotify_name.split())
result_words = set(clean_result_title.split()) | set(clean_result_filename.split())
if spotify_words and len(spotify_words) > 0:
word_match_ratio = len(spotify_words.intersection(result_words)) / len(spotify_words)
if word_match_ratio >= 0.8: # 80% of words match
match_types.append(('word_match_high', int(80 * word_match_ratio)))
elif word_match_ratio >= 0.6: # 60% of words match
match_types.append(('word_match_medium', int(60 * word_match_ratio)))
# FUZZY MATCH (lower confidence for complex cases)
# Handle cases like "Track Name - Artist Name [320kbps]"
simplified_result = self.simplify_complex_title(result_text)
if clean_spotify_name in simplified_result:
match_types.append(('fuzzy_match', 70))
# Return the best match type found
if match_types:
best_match = max(match_types, key=lambda x: x[1])
return {'type': best_match[0], 'confidence': best_match[1], 'matched': True}
else:
return {'type': 'no_match', 'confidence': 0, 'matched': False}
def normalize_track_title(self, title):
"""Normalize track title by removing common formatting and extra content"""
if not title:
return ""
import re
# Convert to lowercase and strip
normalized = title.lower().strip()
# Remove file extensions
normalized = re.sub(r'\.(flac|mp3|aac|alac|ape|ogg|m4a)$', '', normalized)
# Remove common bracketed content (but preserve essential parts)
# Remove quality indicators: [320kbps], [FLAC], [24bit], etc.
normalized = re.sub(r'\[(320|256|192|128)k?bps?\]', '', normalized)
normalized = re.sub(r'\[flac\]', '', normalized)
normalized = re.sub(r'\[24bit\]', '', normalized)
normalized = re.sub(r'\[hi-?res\]', '', normalized)
# Remove track numbers: "01. ", "1-", "01 - "
normalized = re.sub(r'^(\d{1,2}[-.\s]*)', '', normalized)
# Remove common separators between track and artist when they appear together
# "Track Name - Artist Name" -> focus on track name part
if ' - ' in normalized:
parts = normalized.split(' - ')
# Usually the first part is the track name
if len(parts) >= 2:
normalized = parts[0].strip()
# Remove featuring info: "(feat. Artist)", "ft. Artist", etc.
normalized = re.sub(r'\(feat\.?[^)]*\)', '', normalized)
normalized = re.sub(r'\bft\.?\s+[^,\s]+', '', normalized)
normalized = re.sub(r'\bfeat\.?\s+[^,\s]+', '', normalized)
# Remove common extra content
normalized = re.sub(r'\(remix\)', '', normalized)
normalized = re.sub(r'\(remaster\)', '', normalized)
normalized = re.sub(r'\(official[^)]*\)', '', normalized)
# Replace multiple separators with spaces
normalized = re.sub(r'[_\-\.\s]+', ' ', normalized)
# Remove extra whitespace
normalized = re.sub(r'\s+', ' ', normalized).strip()
return normalized
def simplify_complex_title(self, text):
"""Simplify complex titles that may have artist names, quality info, etc."""
import re
# Remove everything in brackets and parentheses
simplified = re.sub(r'\[[^\]]*\]', '', text)
simplified = re.sub(r'\([^)]*\)', '', text)
# Remove common quality indicators
simplified = re.sub(r'\b(320|256|192|128)k?bps?\b', '', simplified)
simplified = re.sub(r'\bflac\b', '', simplified)
simplified = re.sub(r'\bmp3\b', '', simplified)
# Remove excessive punctuation
simplified = re.sub(r'[_\-\.\s]+', ' ', simplified)
simplified = re.sub(r'\s+', ' ', simplified).strip()
return simplified
def start_download_with_match(self, search_result, spotify_track, track_index, table_index):
"""Start download using the matched search result and downloads.py infrastructure"""
print(f"🚀 Starting download with matched result: {search_result.filename}")
# Update table to show downloading status
if table_index is not None:
downloading_item = QTableWidgetItem("⏬ Downloading")
downloading_item.setFlags(downloading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
downloading_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(table_index, 4, downloading_item)
# Update console
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f"🎵 Downloading: {search_result.filename}")
# Use downloads.py infrastructure for the actual download with auto-matching
self.start_matched_download_via_infrastructure(search_result, track_index, table_index)
# Move to next track search
self.advance_to_next_track()
def on_search_failed(self, spotify_track, track_index, table_index):
"""Handle case where all search queries failed"""
track_name = spotify_track.name
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
print(f"❌ All search strategies failed for: {track_name} by {artist_name}")
# Update console
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f"❌ No results found: {track_name} by {artist_name}")
# Update table to show failed status
if table_index is not None:
failed_item = QTableWidgetItem("❌ No Results")
failed_item.setFlags(failed_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
failed_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(table_index, 4, failed_item)
# Update completion tracking
self.completed_downloads += 1
self.failed_downloads += 1
self.download_progress.setValue(self.completed_downloads)
# Move to next track search
self.advance_to_next_track()
def advance_to_next_track(self):
"""Move to searching the next track"""
# Check for cancellation
if hasattr(self, 'cancel_requested') and self.cancel_requested:
print("🛑 Track advancement cancelled by user")
return
self.current_search_index += 1
self.start_next_track_search()
def create_search_result_from_spotify_track(self, spotify_track):
"""Create a mock search result object from Spotify track to work with downloads.py"""
from dataclasses import dataclass
@dataclass
class MockSearchResult:
filename: str
user: str = "spotify_match"
size: int = 0
bit_rate: int = 0
sample_rate: int = 0
duration: int = 0
format: str = "flac"
title: str = ""
artist: str = ""
album: str = ""
track_number: int = 0
# Create mock search result with Spotify metadata
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
track_name = spotify_track.name
album_name = getattr(spotify_track, 'album', 'Unknown Album')
duration_seconds = int(spotify_track.duration_ms / 1000) if hasattr(spotify_track, 'duration_ms') else 0
search_result = MockSearchResult(
filename=f"{artist_name} - {track_name}.flac",
title=track_name,
artist=artist_name,
album=album_name,
duration=duration_seconds,
size=50000000, # Assume ~50MB for FLAC
bit_rate=1411, # Standard FLAC bitrate
sample_rate=44100,
format="flac"
)
return search_result
def create_spotify_based_search_result(self, original_search_result, spotify_track, spotify_artist):
"""Create a search result using Spotify metadata instead of Soulseek metadata"""
from dataclasses import dataclass
# Debug: Check what type of search result we received
print(f"🔍 Debug - original_search_result type: {type(original_search_result)}")
print(f"🔍 Debug - original_search_result attributes: {dir(original_search_result)}")
if hasattr(original_search_result, 'filename'):
print(f"🔍 Debug - filename: {original_search_result.filename}")
if hasattr(original_search_result, 'user'):
print(f"🔍 Debug - user: {original_search_result.user}")
else:
print(f"🔍 Debug - NO USER ATTRIBUTE FOUND")
@dataclass
class SpotifyBasedSearchResult:
# Soulseek download details - using expected field names
filename: str
username: str # downloads.py expects 'username' not 'user'
size: int
bitrate: int # downloads.py expects 'bitrate' not 'bit_rate'
sample_rate: int
duration: int
quality: str # downloads.py expects 'quality' not 'format'
# Spotify metadata for organization
title: str
artist: str
album: str
track_number: int = 0
# Add compatibility properties for any code expecting old names
@property
def user(self):
return self.username
@property
def bit_rate(self):
return self.bitrate
@property
def format(self):
return self.quality
# Get Spotify metadata
spotify_title = spotify_track.name
spotify_artist_name = spotify_artist.name
spotify_album = getattr(spotify_track, 'album', 'Unknown Album')
spotify_duration = int(spotify_track.duration_ms / 1000) if hasattr(spotify_track, 'duration_ms') else 0
# Determine track number if this is part of an album
track_number = getattr(spotify_track, 'track_number', 0) if hasattr(spotify_track, 'track_number') else 0
# Create hybrid result - Soulseek download data + Spotify metadata
# Map TrackResult attributes to expected format
spotify_based_result = SpotifyBasedSearchResult(
# Soulseek download details (keep for actual download) - map attributes correctly
filename=getattr(original_search_result, 'filename', f"{spotify_title}.flac"),
username=getattr(original_search_result, 'username', 'unknown_user'), # TrackResult uses 'username'
size=getattr(original_search_result, 'size', 50000000),
bitrate=getattr(original_search_result, 'bitrate', 1411), # TrackResult uses 'bitrate'
sample_rate=getattr(original_search_result, 'sample_rate', 44100),
duration=getattr(original_search_result, 'duration', spotify_duration),
quality=getattr(original_search_result, 'quality', 'flac'), # TrackResult uses 'quality'
# Spotify metadata (used for folder organization)
title=spotify_title,
artist=spotify_artist_name,
album=spotify_album,
track_number=track_number
)
print(f"🎯 Created Spotify-based search result:")
print(f" 📁 Title: {spotify_title} (Spotify)")
print(f" 🎤 Artist: {spotify_artist_name} (Spotify)")
print(f" 💿 Album: {spotify_album} (Spotify)")
print(f" 📄 File: {original_search_result.filename} (Soulseek)")
return spotify_based_result
def start_matched_download_via_infrastructure(self, search_result, track_index, table_index):
"""Start matched download using downloads.py infrastructure with Spotify metadata"""
try:
# Get the Spotify track for artist info
track_result = self.missing_tracks[track_index]
spotify_track = track_result.spotify_track
# Use the first artist from Spotify as the "matched" artist
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
# Create an Artist object compatible with downloads.py
from dataclasses import dataclass
@dataclass
class SpotifyArtist:
name: str
id: str = ""
image_url: str = ""
popularity: int = 50
genres: list = None
def __post_init__(self):
if self.genres is None:
self.genres = []
artist = SpotifyArtist(name=artist_name)
# CREATE SPOTIFY-BASED SEARCH RESULT instead of using Soulseek metadata
# This ensures folder organization uses Spotify metadata, not Soulseek metadata
spotify_based_result = self.create_spotify_based_search_result(search_result, spotify_track, artist)
# ADD VALIDATION DATA to the search result for later verification
spotify_based_result.original_spotify_track = spotify_track # Store for validation
spotify_based_result.validation_required = True
# Call downloads.py infrastructure with Spotify-based search result
# This ensures proper folder organization using Spotify metadata
download_item = self.downloads_page._start_download_with_artist(spotify_based_result, artist)
if download_item:
print(f"✅ Successfully queued download for: {spotify_track.name}")
# Set up completion tracking
self.track_download_items = getattr(self, 'track_download_items', {})
self.track_download_items[download_item] = (track_index, table_index)
# Monitor download completion
self.monitor_download_completion(download_item, track_index, table_index)
else:
# Download failed to start
self.on_track_download_failed_infrastructure(track_index, table_index, "Failed to start download")
except Exception as e:
print(f"❌ Error starting download via infrastructure: {str(e)}")
self.on_track_download_failed_infrastructure(track_index, table_index, str(e))
def monitor_download_completion(self, download_item, track_index, table_index):
"""Monitor download completion using QTimer"""
from PyQt6.QtCore import QTimer
# Create timer to check download status
timer = QTimer()
timer.timeout.connect(lambda: self.check_download_status(download_item, track_index, table_index, timer))
timer.start(1000) # Check every second
# Store timer reference for cleanup
if not hasattr(self, 'download_timers'):
self.download_timers = []
self.download_timers.append(timer)
def check_download_status(self, download_item, track_index, table_index, timer):
"""Check if download is complete"""
try:
# Check if download item still exists and get its status
if hasattr(download_item, 'download_id') and download_item.download_id:
# Use async function to get download status properly
self.check_download_status_async(download_item, track_index, table_index, timer)
return
# Check if timer has been running too long (timeout after 5 minutes)
if not hasattr(timer, 'start_time'):
timer.start_time = 0
timer.start_time += 1
if timer.start_time > 300: # 5 minutes timeout
timer.stop()
self.on_track_download_failed_infrastructure(track_index, table_index, "Download timeout")
except Exception as e:
print(f"❌ Error checking download status: {str(e)}")
timer.stop()
self.on_track_download_failed_infrastructure(track_index, table_index, str(e))
def check_download_status_async(self, download_item, track_index, table_index, timer):
"""Check download status using proper async handling"""
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
class DownloadStatusWorkerSignals(QObject):
status_checked = pyqtSignal(str, int, int, object) # state, track_index, table_index, timer
check_failed = pyqtSignal(str, int, int, object) # error, track_index, table_index, timer
class DownloadStatusWorker(QRunnable):
def __init__(self, soulseek_client, download_id):
super().__init__()
self.soulseek_client = soulseek_client
self.download_id = download_id
self.signals = DownloadStatusWorkerSignals()
def run(self):
loop = None
try:
import asyncio
# Create fresh event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Get downloads with proper await
downloads = loop.run_until_complete(self.soulseek_client.get_all_downloads())
# Find our download
for download in downloads:
if download.id == self.download_id:
self.signals.status_checked.emit(download.state, track_index, table_index, timer)
return
# Download not found - might be completed already
self.signals.status_checked.emit("NotFound", track_index, table_index, timer)
except Exception as e:
self.signals.check_failed.emit(str(e), track_index, table_index, timer)
finally:
if loop:
try:
# Clean up
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
except Exception:
pass
# Create and start worker
worker = DownloadStatusWorker(self.parent_page.soulseek_client, download_item.download_id)
worker.signals.status_checked.connect(self.on_download_status_checked)
worker.signals.check_failed.connect(self.on_download_status_check_failed)
# Submit to thread pool
if hasattr(self.parent_page, 'thread_pool'):
self.parent_page.thread_pool.start(worker)
else:
thread_pool = QThreadPool()
self.fallback_pools.append(thread_pool)
thread_pool.start(worker)
def on_download_status_checked(self, state, track_index, table_index, timer):
"""Handle download status check result"""
if state == "Completed":
timer.stop()
self.on_track_download_complete_infrastructure(track_index, table_index)
elif state in ["Cancelled", "Failed"]:
timer.stop()
self.on_track_download_failed_infrastructure(track_index, table_index, f"Download {state.lower()}")
elif state == "NotFound":
timer.stop()
self.on_track_download_complete_infrastructure(track_index, table_index) # Assume completed
# For "InProgress" or other states, timer continues
def on_download_status_check_failed(self, error, track_index, table_index, timer):
"""Handle download status check failure"""
print(f"❌ Download status check failed: {error}")
timer.stop()
self.on_track_download_failed_infrastructure(track_index, table_index, f"Status check failed: {error}")
def on_track_download_complete_infrastructure(self, track_index, table_index):
"""Handle successful track download via infrastructure - with Spotify validation"""
print(f"🔍 Download {track_index + 1} completed, starting Spotify validation...")
# Get the original track for validation
if track_index < len(self.missing_tracks):
original_track = self.missing_tracks[track_index].spotify_track
self.validate_downloaded_track(track_index, table_index, original_track)
else:
print(f"❌ Track index {track_index} out of range, marking as failed")
self.on_track_download_failed_infrastructure(track_index, table_index, "Track index out of range")
def validate_downloaded_track(self, track_index, table_index, original_track):
"""Validate that downloaded track matches original Spotify track via API lookup"""
print(f"🎯 Validating: {original_track.name} by {original_track.artists[0] if original_track.artists else 'Unknown'}")
# Update table to show validation in progress
if table_index is not None:
validating_item = QTableWidgetItem("🔍 Validating")
validating_item.setFlags(validating_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
validating_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(table_index, 4, validating_item)
# Update console
if hasattr(self.parent_page, 'log_area'):
track_name = original_track.name
artist_name = original_track.artists[0] if original_track.artists else "Unknown"
self.parent_page.log_area.append(f"🔍 Validating download: {track_name} by {artist_name}")
# Start Spotify validation worker
self.start_spotify_validation_worker(track_index, table_index, original_track)
def start_spotify_validation_worker(self, track_index, table_index, original_track):
"""Start background worker to validate track via Spotify API"""
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
class SpotifyValidationWorkerSignals(QObject):
validation_completed = pyqtSignal(bool, str, int, int, object) # is_valid, reason, track_index, table_index, original_track
validation_failed = pyqtSignal(str, int, int, object) # error, track_index, table_index, original_track
class SpotifyValidationWorker(QRunnable):
def __init__(self, spotify_client, original_track):
super().__init__()
self.spotify_client = spotify_client
self.original_track = original_track
self.signals = SpotifyValidationWorkerSignals()
def run(self):
try:
# Search Spotify for the track to get API response
original_artist = self.original_track.artists[0] if self.original_track.artists else ""
original_title = self.original_track.name
print(f"🔍 Spotify API lookup: '{original_title}' by '{original_artist}'")
# Search Spotify API for this track
search_query = f"track:{original_title} artist:{original_artist}"
spotify_results = self.spotify_client.search_tracks(search_query, limit=5)
if not spotify_results:
self.signals.validation_completed.emit(False, "No Spotify API results found", track_index, table_index, self.original_track)
return
# Check if any result matches our original track artist
for result in spotify_results:
if result.artists:
spotify_api_artist = result.artists[0].lower().strip()
original_artist_clean = original_artist.lower().strip()
# Exact match validation
if spotify_api_artist == original_artist_clean:
print(f"✅ Validation passed: Spotify API confirms '{result.name}' by '{result.artists[0]}'")
self.signals.validation_completed.emit(True, f"Spotify API confirmed artist match: {result.artists[0]}", track_index, table_index, self.original_track)
return
# No matching artist found
found_artists = [r.artists[0] if r.artists else "Unknown" for r in spotify_results[:3]]
reason = f"Artist mismatch. Expected: '{original_artist}', Spotify API returned: {found_artists}"
self.signals.validation_completed.emit(False, reason, track_index, table_index, self.original_track)
except Exception as e:
self.signals.validation_failed.emit(str(e), track_index, table_index, self.original_track)
# Create and start validation worker
spotify_client = getattr(self.parent_page, 'spotify_client', None)
if not spotify_client:
print("❌ No Spotify client available for validation")
self.on_validation_failed("No Spotify client available", track_index, table_index, original_track)
return
worker = SpotifyValidationWorker(spotify_client, original_track)
worker.signals.validation_completed.connect(self.on_validation_completed)
worker.signals.validation_failed.connect(self.on_validation_failed)
# Submit to thread pool
if hasattr(self.parent_page, 'thread_pool'):
self.parent_page.thread_pool.start(worker)
else:
thread_pool = QThreadPool()
self.fallback_pools.append(thread_pool)
thread_pool.start(worker)
def on_validation_completed(self, is_valid, reason, track_index, table_index, original_track):
"""Handle Spotify validation completion"""
if is_valid:
# Validation passed - mark as truly completed
self.successful_downloads += 1
print(f"✅ Validation passed for track {track_index + 1}: {reason}")
# Update main console log
if hasattr(self.parent_page, 'log_area'):
track_name = original_track.name
artist_name = original_track.artists[0] if original_track.artists else "Unknown"
self.parent_page.log_area.append(f"✅ Downloaded & validated: {track_name} by {artist_name}")
# Update table row
if table_index is not None:
downloaded_item = QTableWidgetItem("✅ Downloaded")
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(table_index, 4, downloaded_item)
# Update progress
self.completed_downloads += 1
self.advance_to_next_track()
else:
# Validation failed - try next search candidate
print(f"❌ Validation failed for track {track_index + 1}: {reason}")
if hasattr(self.parent_page, 'log_area'):
track_name = original_track.name
artist_name = original_track.artists[0] if original_track.artists else "Unknown"
self.parent_page.log_area.append(f"❌ Validation failed: {track_name} by {artist_name} - {reason}")
self.parent_page.log_area.append(f"🔄 Trying next search candidate...")
# Try next search candidate
self.retry_with_next_candidate(track_index, table_index, original_track, reason)
def on_validation_failed(self, error, track_index, table_index, original_track):
"""Handle Spotify validation error"""
print(f"❌ Validation error for track {track_index + 1}: {error}")
if hasattr(self.parent_page, 'log_area'):
track_name = original_track.name
artist_name = original_track.artists[0] if original_track.artists else "Unknown"
self.parent_page.log_area.append(f"❌ Validation error: {track_name} by {artist_name} - {error}")
self.parent_page.log_area.append(f"🔄 Trying next search candidate...")
# Try next search candidate
self.retry_with_next_candidate(track_index, table_index, original_track, error)
def retry_with_next_candidate(self, track_index, table_index, original_track, reason):
"""Try the next search candidate for this track"""
print(f"🔄 Retrying track {track_index + 1} with next search candidate")
# Update table to show retry in progress
if table_index is not None:
retry_item = QTableWidgetItem("🔄 Retrying")
retry_item.setFlags(retry_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
retry_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(table_index, 4, retry_item)
# Check if we have already stored search results for this track
if not hasattr(self, 'track_search_results'):
self.track_search_results = {}
if not hasattr(self, 'track_candidate_index'):
self.track_candidate_index = {}
# Initialize candidate index if first retry
if track_index not in self.track_candidate_index:
self.track_candidate_index[track_index] = 0
# Move to next candidate
self.track_candidate_index[track_index] += 1
# Check if we have more candidates to try
if track_index in self.track_search_results:
candidates = self.track_search_results[track_index]
current_index = self.track_candidate_index[track_index]
if current_index < len(candidates):
# Try next candidate
next_candidate = candidates[current_index]
print(f"🎯 Trying candidate {current_index + 1}/{len(candidates)}: {next_candidate.filename}")
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append(f" 🎯 Trying candidate {current_index + 1}/{len(candidates)}: {next_candidate.filename}")
# Start download with next candidate
self.start_download_with_match(next_candidate, original_track, track_index, table_index)
return
# No more candidates available - mark as failed
print(f"❌ No more candidates available for track {track_index + 1}")
self.on_track_download_failed_infrastructure(track_index, table_index, f"All candidates failed validation. Last reason: {reason}")
def on_track_download_failed_infrastructure(self, track_index, table_index, error_message):
"""Handle failed track download via infrastructure"""
self.failed_downloads += 1
print(f"❌ Download {track_index + 1} failed via infrastructure: {error_message}")
# Update main console log
if hasattr(self.parent_page, 'log_area') and track_index < len(self.missing_tracks):
track = self.missing_tracks[track_index].spotify_track
track_name = track.name
artist_name = track.artists[0] if track.artists else "Unknown Artist"
self.parent_page.log_area.append(f"❌ Download failed: {track_name} by {artist_name} - {error_message}")
# Update table row
if table_index is not None:
failed_item = QTableWidgetItem("❌ Failed")
failed_item.setFlags(failed_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
failed_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(table_index, 4, failed_item)
def download_next_track(self):
"""Download the next missing track"""
if self.current_download >= len(self.missing_tracks):
# All downloads complete
self.on_all_downloads_complete()
return
track_result = self.missing_tracks[self.current_download]
track = track_result.spotify_track
track_index = self.find_track_index(track)
print(f"🎵 Downloading track {self.current_download + 1}/{len(self.missing_tracks)}: {track.name}")
# Update main console log
if hasattr(self.parent_page, 'log_area'):
artist_name = track.artists[0] if track.artists else "Unknown Artist"
progress_pct = ((self.current_download + 1) / len(self.missing_tracks)) * 100
self.parent_page.log_area.append(f"🎵 Downloading ({self.current_download + 1}/{len(self.missing_tracks)}, {progress_pct:.0f}%): {track.name} by {artist_name}")
# Update table to show downloading status
if track_index is not None:
downloading_item = QTableWidgetItem("⏬ Downloading")
downloading_item.setFlags(downloading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
downloading_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(track_index, 4, downloading_item)
# Create download worker
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)
# Track worker for cleanup
self.active_workers.append(worker)
# Submit to thread pool
if hasattr(self.parent_page, 'thread_pool'):
self.parent_page.thread_pool.start(worker)
else:
thread_pool = QThreadPool()
self.fallback_pools.append(thread_pool)
thread_pool.start(worker)
def find_track_index(self, spotify_track):
"""Find the table row index for a given Spotify track"""
for i, playlist_track in enumerate(self.playlist.tracks):
if (playlist_track.name == spotify_track.name and
playlist_track.artists[0] == spotify_track.artists[0]):
return i
return None
def on_track_download_complete(self, download_index, track_index, download_id):
"""Handle successful track download"""
print(f"✅ Download {download_index + 1} completed: {download_id}")
# Update main console log
if hasattr(self.parent_page, 'log_area') and download_index < len(self.missing_tracks):
track = self.missing_tracks[download_index].spotify_track
track_name = track.name
artist_name = track.artists[0] if track.artists else "Unknown Artist"
remaining = len(self.missing_tracks) - (download_index + 1)
self.parent_page.log_area.append(f"✅ Downloaded: {track_name} by {artist_name} ({remaining} remaining)")
# Update table row
if track_index is not None:
downloaded_item = QTableWidgetItem("✅ Downloaded")
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(track_index, 4, downloaded_item)
# Update progress
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()
def on_track_download_failed(self, download_index, track_index, error_message):
"""Handle failed track download"""
print(f"❌ Download {download_index + 1} failed: {error_message}")
# Update main console log
if hasattr(self.parent_page, 'log_area') and download_index < len(self.missing_tracks):
track = self.missing_tracks[download_index].spotify_track
track_name = track.name
artist_name = track.artists[0] if track.artists else "Unknown Artist"
self.parent_page.log_area.append(f"❌ Download failed: {track_name} by {artist_name} - {error_message}")
# Update table row
if track_index is not None:
failed_item = QTableWidgetItem("❌ Failed")
failed_item.setFlags(failed_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
failed_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(track_index, 4, failed_item)
# Update progress and continue
self.current_download += 1
self.download_progress.setValue(self.current_download)
# Continue with next download
self.download_next_track()
def on_all_downloads_complete(self):
"""Handle completion of all downloads"""
self.download_in_progress = False
print("🎉 All downloads completed!")
# Use our tracked statistics
completed_count = self.successful_downloads
failed_count = self.failed_downloads
# Update main console log with final statistics
if hasattr(self.parent_page, 'log_area'):
total_requested = len(self.missing_tracks)
success_rate = (completed_count / total_requested * 100) if total_requested > 0 else 0
self.parent_page.log_area.append(f"🎉 Download operation complete!")
self.parent_page.log_area.append(f"📊 Results: {completed_count}/{total_requested} successful ({success_rate:.1f}%)")
if failed_count > 0:
self.parent_page.log_area.append(f"⚠️ {failed_count} downloads failed - tracks may need manual search")
# 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()
# Re-enable refresh button - downloads complete
if hasattr(self.parent_page, 'enable_refresh_button'):
self.parent_page.enable_refresh_button()
QMessageBox.information(self, "Downloads Complete",
f"Completed downloading {completed_count}/{len(self.missing_tracks)} missing tracks!")
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
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()
# Re-enable refresh button - background operations complete
if hasattr(self.parent_page, 'enable_refresh_button'):
self.parent_page.enable_refresh_button()
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"
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)"""
from PyQt6.QtCore import QTimer
self.current_download = 0
self.download_timer = QTimer()
self.download_timer.timeout.connect(self.simulate_next_download)
self.download_timer.start(1500) # Simulate 1.5 seconds per download
def simulate_next_download(self):
"""Simulate next download completion"""
if self.current_download < len(self.missing_tracks):
# Find the track in the table and update its status
missing_result = self.missing_tracks[self.current_download]
# Find track index in original playlist
track_index = None
for i, track in enumerate(self.playlist.tracks):
if track.id == missing_result.spotify_track.id:
track_index = i
break
if track_index is not None:
# Update Downloaded column
downloaded_item = QTableWidgetItem("✅ Complete")
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(track_index, 4, downloaded_item)
# Update progress
self.current_download += 1
self.download_progress.setValue(self.current_download)
else:
# All downloads complete
self.download_timer.stop()
self.begin_search_btn.setText("Downloads Complete")
QMessageBox.information(self, "Downloads Complete",
f"Successfully downloaded {len(self.missing_tracks)} missing tracks!")
def on_cancel_clicked(self):
"""Handle Cancel button - cancels operations and closes modal"""
self.cancel_operations()
self.reject() # Close modal with cancel result
def on_close_clicked(self):
"""Handle Close button - closes modal without canceling operations"""
# If operations are in progress, set up background status updates
if self.download_in_progress or not self.analysis_complete:
self.setup_background_status_updates()
# Close modal without canceling operations
self.reject()
def cancel_operations(self):
"""Cancel any ongoing operations"""
print("🛑 Cancelling all operations...")
# Set cancellation flag to stop ongoing processes
self.cancel_requested = True
# Cancel all active workers
if hasattr(self, 'active_workers'):
for worker in self.active_workers:
if hasattr(worker, 'cancel'):
worker.cancel()
# Also try to set stop flag for our custom workers
if hasattr(worker, '_stop_requested'):
worker._stop_requested = True
# Stop all download monitoring timers
if hasattr(self, 'download_timers'):
for timer in self.download_timers:
if timer.isActive():
timer.stop()
self.download_timers.clear()
# Stop analysis/download timer
if hasattr(self, 'download_timer'):
self.download_timer.stop()
# Stop status update timer
if hasattr(self, 'status_update_timer'):
self.status_update_timer.stop()
# Cancel any pending downloads via downloads.py infrastructure
if hasattr(self, 'track_download_items'):
for download_item, (track_index, table_index) in self.track_download_items.items():
try:
if hasattr(download_item, 'download_id') and download_item.download_id:
# Try to cancel download
print(f"🛑 Attempting to cancel download: {download_item.download_id}")
# Note: Actual cancellation would need to be implemented in downloads.py
except Exception as e:
print(f"❌ Error cancelling download: {e}")
# Update main console
if hasattr(self.parent_page, 'log_area'):
self.parent_page.log_area.append("🛑 Download operations cancelled by user")
# 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
self.cancel_requested = False # Reset for next time
# Reset search state
if hasattr(self, 'current_search_index'):
self.current_search_index = 0
if hasattr(self, 'track_search_results'):
self.track_search_results.clear()
if hasattr(self, 'track_candidate_index'):
self.track_candidate_index.clear()
# Reset playlist status indicator
playlist_item = self.find_playlist_item()
if playlist_item:
playlist_item.hide_operation_status()
# Re-enable refresh button - operations cancelled
if hasattr(self.parent_page, 'enable_refresh_button'):
self.parent_page.enable_refresh_button()
print("🛑 All operations cancelled successfully")
def closeEvent(self, event):
"""Handle modal close event"""
print("🔄 DownloadMissingTracksModal closing...")
# Clean up any timers first to prevent reentrant modal session errors
if hasattr(self, 'download_timers'):
for timer in self.download_timers:
try:
if timer.isActive():
timer.stop()
except Exception:
pass
self.download_timers.clear()
# 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
else:
# If no operations in progress, re-enable refresh button
if hasattr(self.parent_page, 'enable_refresh_button'):
self.parent_page.enable_refresh_button()
# Clean up workers
try:
self.cleanup_workers()
except Exception as e:
print(f"⚠️ Error cleaning up workers: {e}")
# Only cancel if user explicitly clicked Cancel
# For Close button or X button, preserve operations
event.accept()
print("✅ DownloadMissingTracksModal closed")
def cleanup_workers(self):
"""Clean up all active workers and thread pools"""
# Cancel active workers first
for worker in self.active_workers:
try:
if hasattr(worker, 'cancel'):
worker.cancel()
elif hasattr(worker, '_stop_requested'):
worker._stop_requested = True
except (RuntimeError, AttributeError):
pass
# Clean up fallback thread pools with timeout
for pool in self.fallback_pools:
try:
pool.clear() # Cancel pending workers
if not pool.waitForDone(1000): # Wait 1 second max
pool.clear() # Force termination
except (RuntimeError, AttributeError):
pass
# Clear tracking lists
self.active_workers.clear()
self.fallback_pools.clear()