2384 lines
No EOL
89 KiB
Python
2384 lines
No EOL
89 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 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)
|
|
|
|
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
|
|
|
|
print("✅ Plex client available")
|
|
|
|
# Create and show the enhanced download missing tracks modal
|
|
try:
|
|
print("🚀 Creating modal...")
|
|
modal = DownloadMissingTracksModal(self.playlist, self.parent_page, self)
|
|
print("✅ Modal created successfully")
|
|
|
|
# Store modal reference to prevent garbage collection
|
|
self.download_modal = modal
|
|
|
|
print("🖥️ Showing 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 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
|
|
action_btn = QPushButton("Sync / Download")
|
|
action_btn.setFixedSize(120, 30) # Slightly wider for longer text
|
|
action_btn.clicked.connect(self.on_view_details_clicked)
|
|
action_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: transparent;
|
|
border: 1px solid #1db954;
|
|
border-radius: 15px;
|
|
color: #1db954;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1db954;
|
|
color: #000000;
|
|
}
|
|
""")
|
|
|
|
layout.addWidget(self.checkbox)
|
|
layout.addLayout(content_layout)
|
|
layout.addStretch()
|
|
layout.addWidget(action_btn)
|
|
|
|
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)
|
|
|
|
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, parent=None):
|
|
super().__init__(parent)
|
|
self.spotify_client = spotify_client
|
|
self.plex_client = plex_client
|
|
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 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):
|
|
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
|
|
|
|
# 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)
|
|
|
|
# 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"""
|
|
self.begin_search_btn.setEnabled(False)
|
|
self.begin_search_btn.setText("Searching Plex...")
|
|
|
|
# Show and reset analysis progress bar
|
|
self.analysis_progress.setVisible(True)
|
|
self.analysis_progress.setMaximum(self.total_tracks)
|
|
self.analysis_progress.setValue(0)
|
|
|
|
# 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")
|
|
|
|
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)
|
|
|
|
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 UI
|
|
self.begin_search_btn.setText("Analysis Complete")
|
|
|
|
if self.missing_tracks:
|
|
# Automatically start download progress
|
|
self.start_download_progress()
|
|
else:
|
|
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
|
|
self.begin_search_btn.setEnabled(True)
|
|
self.begin_search_btn.setText("Begin Search")
|
|
self.analysis_progress.setVisible(False)
|
|
|
|
def start_download_progress(self):
|
|
"""Start download progress tracking (simulated for now)"""
|
|
print(f"🚀 Starting download progress for {len(self.missing_tracks)} tracks")
|
|
|
|
# Show download progress bar
|
|
self.download_progress.setVisible(True)
|
|
self.download_progress.setMaximum(len(self.missing_tracks))
|
|
self.download_progress.setValue(0)
|
|
|
|
# TODO: Implement actual download integration
|
|
# For now, simulate download progress
|
|
self.simulate_downloads()
|
|
|
|
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"""
|
|
# Don't cancel operations, just close modal
|
|
self.reject() # Close modal
|
|
|
|
def cancel_operations(self):
|
|
"""Cancel any ongoing operations"""
|
|
# Cancel workers
|
|
for worker in self.active_workers:
|
|
if hasattr(worker, 'cancel'):
|
|
worker.cancel()
|
|
|
|
# Stop timers
|
|
if hasattr(self, 'download_timer'):
|
|
self.download_timer.stop()
|
|
|
|
print("🛑 Operations cancelled")
|
|
|
|
def closeEvent(self, event):
|
|
"""Handle modal close event"""
|
|
# Only cancel if user explicitly clicked Cancel
|
|
# For Close button or X button, preserve operations
|
|
event.accept() |