|
|
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
|
|
QFrame, QPushButton, QListWidget, QListWidgetItem,
|
|
|
QProgressBar, QTextEdit, QCheckBox, QComboBox,
|
|
|
QScrollArea, QSizePolicy, QMessageBox, QDialog,
|
|
|
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QLineEdit)
|
|
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject
|
|
|
from PyQt6.QtGui import QFont
|
|
|
import os
|
|
|
import json
|
|
|
from datetime import datetime
|
|
|
from dataclasses import dataclass
|
|
|
from typing import List, Optional
|
|
|
from core.soulseek_client import TrackResult
|
|
|
import re
|
|
|
import asyncio
|
|
|
from core.matching_engine import MusicMatchingEngine
|
|
|
from core.wishlist_service import get_wishlist_service
|
|
|
from ui.components.toast_manager import ToastType
|
|
|
from database.music_database import get_database
|
|
|
from core.plex_scan_manager import PlexScanManager
|
|
|
from utils.logging_config import get_logger
|
|
|
|
|
|
logger = get_logger("sync")
|
|
|
|
|
|
# Define constants for storage
|
|
|
STORAGE_DIR = "storage"
|
|
|
STATUS_FILE = os.path.join(STORAGE_DIR, "sync_status.json")
|
|
|
|
|
|
class EllipsisLabel(QLabel):
|
|
|
"""A label that shows ellipsis for long text and tooltip on hover"""
|
|
|
def __init__(self, text="", parent=None):
|
|
|
super().__init__(text, parent)
|
|
|
self.full_text = text
|
|
|
self.setText(text)
|
|
|
|
|
|
def setText(self, text):
|
|
|
self.full_text = text
|
|
|
# Set elided text with ellipsis
|
|
|
fm = self.fontMetrics()
|
|
|
elided_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, self.width() - 10)
|
|
|
super().setText(elided_text)
|
|
|
|
|
|
# Set tooltip to show full text if it's elided
|
|
|
if elided_text != text:
|
|
|
self.setToolTip(text)
|
|
|
else:
|
|
|
self.setToolTip("") # Clear tooltip if text fits
|
|
|
|
|
|
def resizeEvent(self, event):
|
|
|
"""Handle resize events to recalculate ellipsis"""
|
|
|
super().resizeEvent(event)
|
|
|
# Re-elide text with new width
|
|
|
if self.full_text:
|
|
|
fm = self.fontMetrics()
|
|
|
elided_text = fm.elidedText(self.full_text, Qt.TextElideMode.ElideRight, self.width() - 10)
|
|
|
super().setText(elided_text)
|
|
|
|
|
|
# Update tooltip
|
|
|
if elided_text != self.full_text:
|
|
|
self.setToolTip(self.full_text)
|
|
|
else:
|
|
|
self.setToolTip("")
|
|
|
|
|
|
def load_sync_status():
|
|
|
"""Loads the sync status from the JSON file."""
|
|
|
if not os.path.exists(STATUS_FILE):
|
|
|
return {}
|
|
|
try:
|
|
|
with open(STATUS_FILE, 'r') as f:
|
|
|
# Return empty dict if file is empty
|
|
|
content = f.read()
|
|
|
if not content:
|
|
|
return {}
|
|
|
return json.loads(content)
|
|
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
|
# If file is corrupted or not found, return an empty dict
|
|
|
print(f"Warning: Could not read or parse {STATUS_FILE}. Starting with a clean slate.")
|
|
|
return {}
|
|
|
|
|
|
def save_sync_status(data):
|
|
|
"""Saves the sync status to the JSON file."""
|
|
|
try:
|
|
|
os.makedirs(STORAGE_DIR, exist_ok=True)
|
|
|
with open(STATUS_FILE, 'w') as f:
|
|
|
json.dump(data, f, indent=4)
|
|
|
except Exception as e:
|
|
|
print(f"Error saving sync status to {STATUS_FILE}: {e}")
|
|
|
|
|
|
def clean_track_name_for_search(track_name):
|
|
|
"""
|
|
|
Intelligently cleans a track name for searching by removing noise while preserving important version information.
|
|
|
Removes: (feat. Artist), (Explicit), (Clean), etc.
|
|
|
Keeps: (Extended Version), (Live), (Acoustic), (Remix), etc.
|
|
|
"""
|
|
|
if not track_name or not isinstance(track_name, str):
|
|
|
return track_name
|
|
|
|
|
|
cleaned_name = track_name
|
|
|
|
|
|
# Define patterns to REMOVE (noise that doesn't affect track identity)
|
|
|
remove_patterns = [
|
|
|
r'\s*\(explicit\)', # (Explicit)
|
|
|
r'\s*\(clean\)', # (Clean)
|
|
|
r'\s*\(radio\s*edit\)', # (Radio Edit)
|
|
|
r'\s*\(radio\s*version\)', # (Radio Version)
|
|
|
r'\s*\(feat\.?\s*[^)]+\)', # (feat. Artist) or (ft. Artist)
|
|
|
r'\s*\(ft\.?\s*[^)]+\)', # (ft Artist)
|
|
|
r'\s*\(featuring\s*[^)]+\)', # (featuring Artist)
|
|
|
r'\s*\(with\s*[^)]+\)', # (with Artist)
|
|
|
r'\s*\[[^\]]*explicit[^\]]*\]', # [Explicit] in brackets
|
|
|
r'\s*\[[^\]]*clean[^\]]*\]', # [Clean] in brackets
|
|
|
]
|
|
|
|
|
|
# Apply removal patterns
|
|
|
for pattern in remove_patterns:
|
|
|
cleaned_name = re.sub(pattern, '', cleaned_name, flags=re.IGNORECASE).strip()
|
|
|
|
|
|
# PRESERVE important version information (do NOT remove these)
|
|
|
# These patterns are intentionally NOT in the remove list:
|
|
|
# - (Extended Version), (Extended), (Long Version)
|
|
|
# - (Live), (Live Version), (Concert)
|
|
|
# - (Acoustic), (Acoustic Version)
|
|
|
# - (Remix), (Club Mix), (Dance Mix)
|
|
|
# - (Remastered), (Remaster)
|
|
|
# - (Demo), (Studio Version)
|
|
|
# - (Instrumental)
|
|
|
# - Album/year info like (2023), (Deluxe Edition)
|
|
|
|
|
|
# If cleaning results in an empty string, return the original track name
|
|
|
if not cleaned_name.strip():
|
|
|
return track_name
|
|
|
|
|
|
# Log cleaning if significant changes were made
|
|
|
if cleaned_name != track_name:
|
|
|
print(f"🧹 Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'")
|
|
|
|
|
|
return cleaned_name
|
|
|
|
|
|
@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
|
|
|
# Instantiate the matching engine once per worker for efficiency
|
|
|
self.matching_engine = MusicMatchingEngine()
|
|
|
|
|
|
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)
|
|
|
# Use the 0.8 confidence threshold
|
|
|
if plex_match and confidence >= 0.8:
|
|
|
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:
|
|
|
import traceback
|
|
|
traceback.print_exc()
|
|
|
self.signals.analysis_failed.emit(str(e))
|
|
|
|
|
|
def _check_track_in_plex(self, spotify_track):
|
|
|
"""
|
|
|
Check if a Spotify track exists in the database by searching for each artist and
|
|
|
stopping as soon as a confident match is found.
|
|
|
Now uses local database instead of Plex API for much faster performance.
|
|
|
"""
|
|
|
try:
|
|
|
original_title = spotify_track.name
|
|
|
|
|
|
# Get database instance
|
|
|
db = get_database()
|
|
|
|
|
|
# --- Generate conservative title variations (preserve meaningful differences) ---
|
|
|
title_variations = [original_title]
|
|
|
|
|
|
# Only add cleaned version if it removes clear noise (not meaningful content like remixes)
|
|
|
cleaned_for_search = clean_track_name_for_search(original_title)
|
|
|
if cleaned_for_search.lower() != original_title.lower():
|
|
|
title_variations.append(cleaned_for_search)
|
|
|
|
|
|
# Use matching engine's conservative clean_title (no longer strips remixes/versions)
|
|
|
base_title = self.matching_engine.clean_title(original_title)
|
|
|
if base_title.lower() not in [t.lower() for t in title_variations]:
|
|
|
title_variations.append(base_title)
|
|
|
|
|
|
# DO NOT strip content after dashes - this removes important remix/version info
|
|
|
|
|
|
unique_title_variations = list(dict.fromkeys(title_variations))
|
|
|
|
|
|
# --- Search for each artist with each title variation ---
|
|
|
artists_to_search = spotify_track.artists if spotify_track.artists else [""]
|
|
|
for artist_name in artists_to_search:
|
|
|
if self._cancelled: return None, 0.0
|
|
|
|
|
|
for query_title in unique_title_variations:
|
|
|
if self._cancelled: return None, 0.0
|
|
|
|
|
|
# Use database check_track_exists method with consistent thresholds
|
|
|
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
|
|
|
|
|
|
if db_track and confidence >= 0.7:
|
|
|
print(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
|
|
|
|
|
|
# Convert database track to format compatible with existing code
|
|
|
# Create a mock Plex track object for compatibility
|
|
|
class MockPlexTrack:
|
|
|
def __init__(self, db_track):
|
|
|
self.id = str(db_track.id)
|
|
|
self.title = db_track.title
|
|
|
self.artist_name = db_track.artist_name
|
|
|
self.album_title = db_track.album_title
|
|
|
self.track_number = db_track.track_number
|
|
|
self.duration = db_track.duration
|
|
|
self.file_path = db_track.file_path
|
|
|
|
|
|
mock_track = MockPlexTrack(db_track)
|
|
|
return mock_track, confidence
|
|
|
|
|
|
print(f"❌ No database match found for '{original_title}' by any of the artists {artists_to_search}")
|
|
|
return None, 0.0
|
|
|
|
|
|
except Exception as e:
|
|
|
import traceback
|
|
|
print(f"Error checking track in database: {e}")
|
|
|
traceback.print_exc()
|
|
|
return None, 0.0
|
|
|
|
|
|
|
|
|
class TrackDownloadWorkerSignals(QObject):
|
|
|
"""Signals for track download worker"""
|
|
|
download_started = 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, quality_preference=None):
|
|
|
super().__init__()
|
|
|
self.spotify_track = spotify_track
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.download_index = download_index
|
|
|
self.track_index = track_index
|
|
|
self.quality_preference = quality_preference or 'flac'
|
|
|
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 - prioritize artist + track for better accuracy
|
|
|
track_name = self.spotify_track.name
|
|
|
artist_name = self.spotify_track.artists[0] if self.spotify_track.artists else ""
|
|
|
|
|
|
search_queries = []
|
|
|
# Try artist + track first (more specific, less false matches)
|
|
|
if artist_name:
|
|
|
search_queries.append(f"{artist_name} {track_name}")
|
|
|
# Fallback to track name only if artist search fails
|
|
|
search_queries.append(track_name)
|
|
|
|
|
|
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, self.quality_preference)
|
|
|
)
|
|
|
if download_id:
|
|
|
break # Success - stop trying other queries
|
|
|
finally:
|
|
|
loop.close()
|
|
|
|
|
|
if download_id:
|
|
|
self.signals.download_started.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 SyncStatusProcessingWorkerSignals(QObject):
|
|
|
"""Defines the signals available from the SyncStatusProcessingWorker."""
|
|
|
completed = pyqtSignal(list)
|
|
|
error = pyqtSignal(str)
|
|
|
|
|
|
class SyncStatusProcessingWorker(QRunnable):
|
|
|
"""
|
|
|
Runs download status processing in a background thread for the sync modal.
|
|
|
It checks the slskd API to provide a reliable status, with fallbacks.
|
|
|
This implementation is based on the working logic from downloads.py to restore live updates.
|
|
|
"""
|
|
|
def __init__(self, soulseek_client, download_items_data):
|
|
|
super().__init__()
|
|
|
self.signals = SyncStatusProcessingWorkerSignals()
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.download_items_data = download_items_data
|
|
|
# This worker no longer performs filesystem checks, so it doesn't need transfers_directory.
|
|
|
|
|
|
def run(self):
|
|
|
"""The main logic of the background worker."""
|
|
|
try:
|
|
|
import asyncio
|
|
|
import os
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
|
transfers_data = loop.run_until_complete(
|
|
|
self.soulseek_client._make_request('GET', 'transfers/downloads')
|
|
|
)
|
|
|
loop.close()
|
|
|
|
|
|
results = []
|
|
|
if not transfers_data:
|
|
|
transfers_data = []
|
|
|
|
|
|
# --- FIX: More robustly parse the transfers data ---
|
|
|
# Errored/finished downloads might not be nested inside 'directories'.
|
|
|
# This checks for a 'files' list at both the user and directory levels.
|
|
|
all_transfers = []
|
|
|
for user_data in transfers_data:
|
|
|
# Check for files directly under the user object
|
|
|
if 'files' in user_data and isinstance(user_data['files'], list):
|
|
|
all_transfers.extend(user_data['files'])
|
|
|
# Also check for files nested inside directories
|
|
|
if 'directories' in user_data and isinstance(user_data['directories'], list):
|
|
|
for directory in user_data['directories']:
|
|
|
if 'files' in directory and isinstance(directory['files'], list):
|
|
|
all_transfers.extend(directory['files'])
|
|
|
|
|
|
transfers_by_id = {t['id']: t for t in all_transfers}
|
|
|
|
|
|
for item_data in self.download_items_data:
|
|
|
matching_transfer = None
|
|
|
|
|
|
# Step 1: Try to match by the original download ID.
|
|
|
if item_data.get('download_id'):
|
|
|
matching_transfer = transfers_by_id.get(item_data['download_id'])
|
|
|
|
|
|
# Step 2: If no match by ID, fall back to an exact filename match.
|
|
|
if not matching_transfer:
|
|
|
expected_basename = os.path.basename(item_data['file_path']).lower()
|
|
|
for t in all_transfers:
|
|
|
api_basename = os.path.basename(t.get('filename', '')).lower()
|
|
|
if api_basename == expected_basename:
|
|
|
matching_transfer = t
|
|
|
print(f"ℹ️ Found download for '{expected_basename}' by exact filename match.")
|
|
|
break
|
|
|
|
|
|
if matching_transfer:
|
|
|
state = matching_transfer.get('state', 'Unknown')
|
|
|
progress = matching_transfer.get('percentComplete', 0)
|
|
|
|
|
|
# Determine status with correct priority (Errored/Cancelled before Completed)
|
|
|
if 'Cancelled' in state or 'Canceled' in state:
|
|
|
new_status = 'cancelled'
|
|
|
elif 'Failed' in state or 'Errored' in state:
|
|
|
new_status = 'failed'
|
|
|
elif 'Completed' in state or 'Succeeded' in state:
|
|
|
new_status = 'completed'
|
|
|
elif 'InProgress' in state:
|
|
|
new_status = 'downloading'
|
|
|
else:
|
|
|
new_status = 'queued'
|
|
|
|
|
|
payload = {
|
|
|
'widget_id': item_data['widget_id'],
|
|
|
'status': new_status,
|
|
|
'progress': int(progress),
|
|
|
'transfer_id': matching_transfer.get('id'),
|
|
|
'username': matching_transfer.get('username')
|
|
|
}
|
|
|
results.append(payload)
|
|
|
else:
|
|
|
# If not found in the API, it might have failed or been cancelled.
|
|
|
# Use a grace period before marking as failed.
|
|
|
item_data['api_missing_count'] = item_data.get('api_missing_count', 0) + 1
|
|
|
if item_data['api_missing_count'] >= 3:
|
|
|
expected_filename = os.path.basename(item_data['file_path'])
|
|
|
print(f"❌ Download failed (missing from API after 3 checks): {expected_filename}")
|
|
|
payload = {'widget_id': item_data['widget_id'], 'status': 'failed'}
|
|
|
results.append(payload)
|
|
|
|
|
|
self.signals.completed.emit(results)
|
|
|
except Exception as e:
|
|
|
import traceback
|
|
|
traceback.print_exc()
|
|
|
self.signals.error.emit(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 SyncWorkerSignals(QObject):
|
|
|
"""Signals for sync worker"""
|
|
|
progress = pyqtSignal(object) # SyncProgress
|
|
|
finished = pyqtSignal(object, object) # SyncResult, snapshot_id (can be None)
|
|
|
error = pyqtSignal(str)
|
|
|
|
|
|
class SyncWorker(QRunnable):
|
|
|
"""Background worker for playlist synchronization with real-time progress callbacks"""
|
|
|
|
|
|
def __init__(self, playlist, sync_service, progress_callback=None):
|
|
|
super().__init__()
|
|
|
self.playlist = playlist
|
|
|
self.sync_service = sync_service
|
|
|
self.progress_callback = progress_callback
|
|
|
self.signals = SyncWorkerSignals()
|
|
|
self._cancelled = False
|
|
|
|
|
|
# Connect progress callback
|
|
|
if progress_callback:
|
|
|
self.signals.progress.connect(progress_callback)
|
|
|
|
|
|
def cancel(self):
|
|
|
"""Cancel the sync operation"""
|
|
|
self._cancelled = True
|
|
|
if hasattr(self.sync_service, 'cancel_sync'):
|
|
|
self.sync_service.cancel_sync()
|
|
|
|
|
|
# Clear the progress callback to stop further progress updates
|
|
|
if hasattr(self.sync_service, 'clear_progress_callback'):
|
|
|
self.sync_service.clear_progress_callback(self.playlist.name)
|
|
|
|
|
|
# Log the cancellation request
|
|
|
print(f"DEBUG: SyncWorker.cancel() called for playlist {getattr(self.playlist, 'name', 'unknown')}")
|
|
|
|
|
|
def run(self):
|
|
|
"""Execute the sync operation"""
|
|
|
snapshot_id = None # Define snapshot_id in the outer scope
|
|
|
try:
|
|
|
if self._cancelled:
|
|
|
return
|
|
|
|
|
|
# Set up progress callback for sync service
|
|
|
def on_progress(progress):
|
|
|
if not self._cancelled:
|
|
|
self.signals.progress.emit(progress)
|
|
|
|
|
|
self.sync_service.set_progress_callback(on_progress, self.playlist.name)
|
|
|
|
|
|
# Create new event loop for this thread
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
|
try:
|
|
|
# Run sync with playlist object
|
|
|
result = loop.run_until_complete(
|
|
|
self.sync_service.sync_playlist(self.playlist, download_missing=False)
|
|
|
)
|
|
|
|
|
|
# --- THE FIX ---
|
|
|
# After sync, fetch the new snapshot_id directly from Spotify
|
|
|
# to ensure we have the most up-to-date value.
|
|
|
try:
|
|
|
if hasattr(self.sync_service, 'spotify_client') and self.sync_service.spotify_client:
|
|
|
# Assuming a synchronous method to get a single playlist's metadata
|
|
|
updated_playlist = self.sync_service.spotify_client.get_playlist(self.playlist.id)
|
|
|
if updated_playlist:
|
|
|
snapshot_id = updated_playlist.snapshot_id
|
|
|
print(f"DEBUG: Successfully fetched new snapshot_id: {snapshot_id}")
|
|
|
else:
|
|
|
print(f"WARNING: get_playlist returned None for {self.playlist.name}")
|
|
|
else:
|
|
|
print("WARNING: Could not get snapshot_id, spotify_client not found on sync_service.")
|
|
|
except Exception as e:
|
|
|
print(f"WARNING: Could not fetch updated snapshot_id for {self.playlist.name}: {e}")
|
|
|
|
|
|
if not self._cancelled:
|
|
|
# Emit the result and the (potentially new) snapshot_id
|
|
|
self.signals.finished.emit(result, snapshot_id)
|
|
|
|
|
|
finally:
|
|
|
loop.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
if not self._cancelled:
|
|
|
self.signals.error.emit(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
|
|
|
|
|
|
# Sync state tracking
|
|
|
self.is_syncing = False
|
|
|
self.sync_worker = None
|
|
|
self.sync_status_widget = None
|
|
|
self.sync_button = None
|
|
|
|
|
|
self.setup_ui()
|
|
|
|
|
|
# Restore sync state if playlist is currently syncing
|
|
|
self.restore_sync_state()
|
|
|
|
|
|
# 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 (except sync workers)"""
|
|
|
# Cancel active workers first, but skip sync workers to allow background sync
|
|
|
for worker in self.active_workers:
|
|
|
try:
|
|
|
# Don't cancel sync workers - they should continue in background
|
|
|
if hasattr(worker, 'cancel') and not isinstance(worker, SyncWorker):
|
|
|
worker.cancel()
|
|
|
except (RuntimeError, AttributeError):
|
|
|
pass
|
|
|
|
|
|
# Disconnect signals from active workers to prevent race conditions (except sync workers)
|
|
|
for worker in self.active_workers:
|
|
|
try:
|
|
|
# Don't disconnect sync worker signals - they need to continue updating playlist items
|
|
|
if hasattr(worker, 'signals') and not isinstance(worker, SyncWorker):
|
|
|
# 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(1200, 800)
|
|
|
self.setStyleSheet("""
|
|
|
QDialog {
|
|
|
background: #191414;
|
|
|
color: #ffffff;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
|
main_layout.setContentsMargins(32, 32, 32, 32)
|
|
|
main_layout.setSpacing(24)
|
|
|
|
|
|
# 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.setFixedHeight(120)
|
|
|
header.setStyleSheet("""
|
|
|
QFrame {
|
|
|
background: #282828;
|
|
|
border-radius: 16px;
|
|
|
border: 1px solid #404040;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
layout = QVBoxLayout(header)
|
|
|
layout.setContentsMargins(32, 24, 32, 24)
|
|
|
layout.setSpacing(12)
|
|
|
|
|
|
# Playlist name - larger, more prominent
|
|
|
name_label = QLabel(self.playlist.name)
|
|
|
name_label.setFont(QFont("SF Pro Display", 24, QFont.Weight.Bold))
|
|
|
name_label.setStyleSheet("color: #ffffff; border: none; background: transparent;")
|
|
|
|
|
|
# Playlist info in a more compact horizontal layout
|
|
|
info_layout = QHBoxLayout()
|
|
|
info_layout.setSpacing(24)
|
|
|
|
|
|
# Track count with icon-like styling
|
|
|
track_count = QLabel(f"{self.playlist.total_tracks} tracks")
|
|
|
track_count.setFont(QFont("SF Pro Text", 14, QFont.Weight.Medium))
|
|
|
track_count.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
|
|
|
|
|
|
# Owner with subtle separator
|
|
|
owner = QLabel(f"by {self.playlist.owner}")
|
|
|
owner.setFont(QFont("SF Pro Text", 14))
|
|
|
owner.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
|
|
|
|
|
|
# Status with accent color
|
|
|
visibility = "Public" if self.playlist.public else "Private"
|
|
|
if self.playlist.collaborative:
|
|
|
visibility = "Collaborative"
|
|
|
status = QLabel(visibility)
|
|
|
status.setFont(QFont("SF Pro Text", 14, QFont.Weight.Medium))
|
|
|
status.setStyleSheet("""
|
|
|
color: #1db954;
|
|
|
border: none;
|
|
|
background: rgba(29, 185, 84, 0.1);
|
|
|
padding: 4px 12px;
|
|
|
border-radius: 12px;
|
|
|
""")
|
|
|
|
|
|
info_layout.addWidget(track_count)
|
|
|
info_layout.addWidget(owner)
|
|
|
info_layout.addWidget(status)
|
|
|
info_layout.addStretch()
|
|
|
|
|
|
# Sync status display (hidden by default)
|
|
|
self.sync_status_widget = self.create_sync_status_display()
|
|
|
info_layout.addWidget(self.sync_status_widget)
|
|
|
|
|
|
layout.addWidget(name_label)
|
|
|
layout.addLayout(info_layout)
|
|
|
|
|
|
return header
|
|
|
|
|
|
def create_sync_status_display(self):
|
|
|
"""Create sync status display widget (hidden by default)"""
|
|
|
sync_status = QFrame()
|
|
|
sync_status.setStyleSheet("""
|
|
|
QFrame {
|
|
|
background: rgba(29, 185, 84, 0.1);
|
|
|
border: 1px solid rgba(29, 185, 84, 0.3);
|
|
|
border-radius: 12px;
|
|
|
}
|
|
|
""")
|
|
|
sync_status.setMinimumHeight(36) # Ensure adequate height
|
|
|
sync_status.hide() # Hidden by default
|
|
|
|
|
|
layout = QHBoxLayout(sync_status)
|
|
|
layout.setContentsMargins(12, 8, 12, 8) # Increased margins for better text visibility
|
|
|
layout.setSpacing(12)
|
|
|
|
|
|
# Total tracks
|
|
|
self.total_tracks_label = QLabel("♪ 0")
|
|
|
self.total_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
|
|
self.total_tracks_label.setStyleSheet("color: #ffa500; background: transparent; border: none;")
|
|
|
|
|
|
# Matched tracks
|
|
|
self.matched_tracks_label = QLabel("✓ 0")
|
|
|
self.matched_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
|
|
self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
|
|
|
|
|
# Failed tracks
|
|
|
self.failed_tracks_label = QLabel("✗ 0")
|
|
|
self.failed_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
|
|
self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;")
|
|
|
|
|
|
# Percentage
|
|
|
self.percentage_label = QLabel("0%")
|
|
|
self.percentage_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Bold))
|
|
|
self.percentage_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
|
|
|
|
|
layout.addWidget(self.total_tracks_label)
|
|
|
|
|
|
# Separator 1
|
|
|
sep1 = QLabel("/")
|
|
|
sep1.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
|
|
sep1.setStyleSheet("color: #666666; background: transparent; border: none;")
|
|
|
layout.addWidget(sep1)
|
|
|
|
|
|
layout.addWidget(self.matched_tracks_label)
|
|
|
|
|
|
# Separator 2
|
|
|
sep2 = QLabel("/")
|
|
|
sep2.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
|
|
sep2.setStyleSheet("color: #666666; background: transparent; border: none;")
|
|
|
layout.addWidget(sep2)
|
|
|
|
|
|
layout.addWidget(self.failed_tracks_label)
|
|
|
|
|
|
# Separator 3
|
|
|
sep3 = QLabel("/")
|
|
|
sep3.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium))
|
|
|
sep3.setStyleSheet("color: #666666; background: transparent; border: none;")
|
|
|
layout.addWidget(sep3)
|
|
|
|
|
|
layout.addWidget(self.percentage_label)
|
|
|
|
|
|
return sync_status
|
|
|
|
|
|
def update_sync_status(self, total_tracks=0, matched_tracks=0, failed_tracks=0):
|
|
|
"""Update sync status display"""
|
|
|
if self.sync_status_widget:
|
|
|
self.total_tracks_label.setText(f"♪ {total_tracks}")
|
|
|
self.matched_tracks_label.setText(f"✓ {matched_tracks}")
|
|
|
self.failed_tracks_label.setText(f"✗ {failed_tracks}")
|
|
|
|
|
|
if total_tracks > 0:
|
|
|
processed_tracks = matched_tracks + failed_tracks
|
|
|
percentage = int((processed_tracks / total_tracks) * 100)
|
|
|
self.percentage_label.setText(f"{percentage}%")
|
|
|
else:
|
|
|
self.percentage_label.setText("0%")
|
|
|
|
|
|
def set_sync_button_state(self, is_syncing):
|
|
|
"""Update sync button appearance based on sync state"""
|
|
|
if self.sync_button:
|
|
|
if is_syncing:
|
|
|
# Change to Cancel Sync with red styling
|
|
|
self.sync_button.setText("Cancel Sync")
|
|
|
self.sync_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #e22134;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #ffffff;
|
|
|
font-size: 13px;
|
|
|
font-weight: 600;
|
|
|
font-family: 'SF Pro Text';
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #f44336;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #c62828;
|
|
|
}
|
|
|
""")
|
|
|
else:
|
|
|
# Change back to Sync This Playlist with green styling
|
|
|
self.sync_button.setText("Sync This Playlist")
|
|
|
self.sync_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #1db954;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #ffffff;
|
|
|
font-size: 13px;
|
|
|
font-weight: 600;
|
|
|
font-family: 'SF Pro Text';
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #1ed760;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #169c46;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
def restore_sync_state(self):
|
|
|
"""Restore sync state when modal is reopened"""
|
|
|
# Check if sync is ongoing for this playlist
|
|
|
if self.parent_page and self.parent_page.is_playlist_syncing(self.playlist.id):
|
|
|
self.is_syncing = True
|
|
|
self.set_sync_button_state(True)
|
|
|
|
|
|
# Find playlist item to get current progress
|
|
|
playlist_item = self.parent_page.find_playlist_item_widget(self.playlist.id)
|
|
|
if playlist_item:
|
|
|
# Show sync status widget with current progress
|
|
|
if self.sync_status_widget:
|
|
|
self.sync_status_widget.show()
|
|
|
self.update_sync_status(
|
|
|
playlist_item.sync_total_tracks,
|
|
|
playlist_item.sync_matched_tracks,
|
|
|
playlist_item.sync_failed_tracks
|
|
|
)
|
|
|
|
|
|
def create_track_list(self):
|
|
|
container = QFrame()
|
|
|
container.setStyleSheet("""
|
|
|
QFrame {
|
|
|
background: #282828;
|
|
|
border-radius: 16px;
|
|
|
border: 1px solid #404040;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
layout = QVBoxLayout(container)
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
layout.setSpacing(0)
|
|
|
|
|
|
# Track table with professional styling
|
|
|
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)
|
|
|
|
|
|
# Professional table styling
|
|
|
self.track_table.setStyleSheet("""
|
|
|
QTableWidget {
|
|
|
background: #282828;
|
|
|
border: none;
|
|
|
border-radius: 16px;
|
|
|
gridline-color: transparent;
|
|
|
color: #ffffff;
|
|
|
font-size: 11px;
|
|
|
selection-background-color: rgba(29, 185, 84, 0.2);
|
|
|
}
|
|
|
QTableWidget::item {
|
|
|
padding: 12px 16px;
|
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
|
background: transparent;
|
|
|
}
|
|
|
QTableWidget::item:hover {
|
|
|
background: rgba(255, 255, 255, 0.02);
|
|
|
}
|
|
|
QTableWidget::item:selected {
|
|
|
background: rgba(29, 185, 84, 0.15);
|
|
|
color: #ffffff;
|
|
|
}
|
|
|
QHeaderView {
|
|
|
background: transparent;
|
|
|
border: none;
|
|
|
}
|
|
|
QHeaderView::section {
|
|
|
background: transparent;
|
|
|
color: #b3b3b3;
|
|
|
padding: 12px 16px;
|
|
|
border: none;
|
|
|
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
|
|
font-weight: 600;
|
|
|
font-size: 10px;
|
|
|
text-transform: uppercase;
|
|
|
letter-spacing: 0.5px;
|
|
|
}
|
|
|
QHeaderView::section:hover {
|
|
|
background: rgba(255, 255, 255, 0.02);
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# Populate table with proper styling
|
|
|
if self.playlist.tracks:
|
|
|
for row, track in enumerate(self.playlist.tracks):
|
|
|
# Track name with ellipsis label
|
|
|
track_label = EllipsisLabel(track.name)
|
|
|
track_label.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium))
|
|
|
track_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
|
|
self.track_table.setCellWidget(row, 0, track_label)
|
|
|
|
|
|
# Artist(s) with ellipsis label
|
|
|
artists = ", ".join(track.artists)
|
|
|
artist_label = EllipsisLabel(artists)
|
|
|
artist_label.setFont(QFont("SF Pro Text", 11))
|
|
|
artist_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
|
|
self.track_table.setCellWidget(row, 1, artist_label)
|
|
|
|
|
|
# Album with ellipsis label
|
|
|
album_label = EllipsisLabel(track.album)
|
|
|
album_label.setFont(QFont("SF Pro Text", 11))
|
|
|
album_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
|
|
self.track_table.setCellWidget(row, 2, album_label)
|
|
|
|
|
|
# Duration with standard item (doesn't need scrolling)
|
|
|
duration = self.format_duration(track.duration_ms)
|
|
|
duration_item = QTableWidgetItem(duration)
|
|
|
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
|
duration_item.setFont(QFont("SF Mono", 10))
|
|
|
self.track_table.setItem(row, 3, duration_item)
|
|
|
else:
|
|
|
# Show placeholder while tracks are being loaded
|
|
|
placeholder_item = QTableWidgetItem("Loading tracks...")
|
|
|
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)
|
|
|
|
|
|
# Professional column configuration
|
|
|
header = self.track_table.horizontalHeader()
|
|
|
header.setVisible(True)
|
|
|
header.show()
|
|
|
header.setStretchLastSection(False)
|
|
|
header.setHighlightSections(False)
|
|
|
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
|
|
|
|
# Calculate available width (modal is 1200px, account for margins)
|
|
|
available_width = 1136 # 1200 - 64px margins
|
|
|
|
|
|
# Professional proportional widths
|
|
|
track_width = int(available_width * 0.35) # ~398px
|
|
|
artist_width = int(available_width * 0.28) # ~318px
|
|
|
album_width = int(available_width * 0.28) # ~318px
|
|
|
duration_width = 100 # Fixed 100px
|
|
|
|
|
|
# Apply column widths with proper resize modes
|
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
|
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive)
|
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
|
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
|
|
|
|
|
|
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 for professional look
|
|
|
header.setMinimumSectionSize(120)
|
|
|
|
|
|
# Hide row numbers and configure table behavior
|
|
|
self.track_table.verticalHeader().setVisible(False)
|
|
|
self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
|
self.track_table.setAlternatingRowColors(False)
|
|
|
|
|
|
# Set uniform row height to accommodate the labels properly
|
|
|
self.track_table.verticalHeader().setDefaultSectionSize(40) # Height for each row
|
|
|
|
|
|
layout.addWidget(self.track_table)
|
|
|
|
|
|
return container
|
|
|
|
|
|
def create_buttons(self):
|
|
|
button_layout = QHBoxLayout()
|
|
|
button_layout.setSpacing(16)
|
|
|
button_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
|
# Close button with subtle styling
|
|
|
close_btn = QPushButton("Close")
|
|
|
close_btn.setFixedSize(100, 44)
|
|
|
close_btn.clicked.connect(self.close)
|
|
|
close_btn.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: rgba(255, 255, 255, 0.05);
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
border-radius: 22px;
|
|
|
color: #ffffff;
|
|
|
font-size: 13px;
|
|
|
font-weight: 600;
|
|
|
font-family: 'SF Pro Text';
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
border-color: rgba(255, 255, 255, 0.15);
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: rgba(255, 255, 255, 0.02);
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# Download missing tracks button with outline style
|
|
|
download_btn = QPushButton("Download Missing Tracks")
|
|
|
download_btn.setFixedSize(200, 44)
|
|
|
download_btn.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: transparent;
|
|
|
border: 1px solid #1db954;
|
|
|
border-radius: 22px;
|
|
|
color: #1db954;
|
|
|
font-size: 13px;
|
|
|
font-weight: 600;
|
|
|
font-family: 'SF Pro Text';
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: rgba(29, 185, 84, 0.08);
|
|
|
border-color: #1ed760;
|
|
|
color: #1ed760;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: rgba(29, 185, 84, 0.15);
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# Sync button with primary styling (store reference for state management)
|
|
|
self.sync_button = QPushButton("Sync This Playlist")
|
|
|
self.sync_button.setFixedSize(160, 44)
|
|
|
self.sync_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #1db954;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #ffffff;
|
|
|
font-size: 13px;
|
|
|
font-weight: 600;
|
|
|
font-family: 'SF Pro Text';
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #1ed760;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #169c46;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# Connect button signals
|
|
|
download_btn.clicked.connect(self.on_download_missing_tracks_clicked)
|
|
|
self.sync_button.clicked.connect(self.on_sync_playlist_clicked)
|
|
|
|
|
|
button_layout.addStretch()
|
|
|
button_layout.addWidget(close_btn)
|
|
|
button_layout.addWidget(download_btn)
|
|
|
button_layout.addWidget(self.sync_button)
|
|
|
|
|
|
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 or not self.playlist.tracks:
|
|
|
QMessageBox.warning(self, "Error", "Playlist tracks not loaded")
|
|
|
return
|
|
|
|
|
|
playlist_item_widget = self.parent_page.find_playlist_item_widget(self.playlist.id)
|
|
|
if not playlist_item_widget:
|
|
|
QMessageBox.critical(self, "Error", "Could not find the associated playlist item on the main page.")
|
|
|
return
|
|
|
|
|
|
print("🚀 Creating DownloadMissingTracksModal...")
|
|
|
modal = DownloadMissingTracksModal(self.playlist, playlist_item_widget, self.parent_page, self.parent_page.downloads_page)
|
|
|
|
|
|
playlist_item_widget.download_modal = modal
|
|
|
|
|
|
# --- FIX: Connect the cleanup signal immediately upon creation. ---
|
|
|
# This ensures that when the modal closes for any reason, the SyncPage
|
|
|
# is notified and can run its cleanup logic.
|
|
|
modal.process_finished.connect(
|
|
|
lambda: self.parent_page.on_download_process_finished(self.playlist.id)
|
|
|
)
|
|
|
|
|
|
self.accept()
|
|
|
modal.show()
|
|
|
|
|
|
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 self.is_syncing:
|
|
|
# Cancel sync
|
|
|
self.cancel_sync()
|
|
|
return
|
|
|
|
|
|
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
|
|
|
)
|
|
|
|
|
|
# Start sync
|
|
|
self.start_sync()
|
|
|
|
|
|
def start_sync(self):
|
|
|
"""Start playlist sync operation via parent page"""
|
|
|
if self.parent_page and self.parent_page.start_playlist_sync(self.playlist):
|
|
|
self.is_syncing = True
|
|
|
|
|
|
# Update modal UI state
|
|
|
self.set_sync_button_state(True)
|
|
|
|
|
|
# Show sync status widget
|
|
|
if self.sync_status_widget:
|
|
|
self.sync_status_widget.show()
|
|
|
self.update_sync_status(len(self.playlist.tracks), 0, 0)
|
|
|
|
|
|
def cancel_sync(self):
|
|
|
"""Cancel ongoing sync operation via parent page"""
|
|
|
if self.parent_page and self.parent_page.cancel_playlist_sync(self.playlist.id):
|
|
|
self.is_syncing = False
|
|
|
|
|
|
# Update modal UI state
|
|
|
self.set_sync_button_state(False)
|
|
|
|
|
|
# Hide sync status widget
|
|
|
if self.sync_status_widget:
|
|
|
self.sync_status_widget.hide()
|
|
|
|
|
|
def on_sync_progress(self, playlist_id, progress):
|
|
|
"""Handle sync progress updates (called from parent page)"""
|
|
|
if playlist_id == self.playlist.id:
|
|
|
# Update modal status display
|
|
|
self.update_sync_status(
|
|
|
progress.total_tracks,
|
|
|
progress.matched_tracks,
|
|
|
progress.failed_tracks
|
|
|
)
|
|
|
|
|
|
def on_sync_finished(self, playlist_id, result):
|
|
|
"""Handle sync completion (called from parent page)"""
|
|
|
if playlist_id == self.playlist.id:
|
|
|
self.is_syncing = False
|
|
|
|
|
|
# Update button state
|
|
|
self.set_sync_button_state(False)
|
|
|
|
|
|
# Update final status
|
|
|
self.update_sync_status(
|
|
|
result.total_tracks,
|
|
|
result.matched_tracks,
|
|
|
result.failed_tracks
|
|
|
)
|
|
|
|
|
|
def on_sync_error(self, playlist_id, error_msg):
|
|
|
"""Handle sync error (called from parent page)"""
|
|
|
if playlist_id == self.playlist.id:
|
|
|
self.is_syncing = False
|
|
|
|
|
|
# Update button state
|
|
|
self.set_sync_button_state(False)
|
|
|
|
|
|
# Hide sync status widget
|
|
|
if self.sync_status_widget:
|
|
|
self.sync_status_widget.hide()
|
|
|
|
|
|
# Show error message
|
|
|
QMessageBox.critical(self, "Sync Failed", f"Sync failed: {error_msg}")
|
|
|
|
|
|
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 with ellipsis label
|
|
|
track_label = EllipsisLabel(track.name)
|
|
|
track_label.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium))
|
|
|
track_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
|
|
self.track_table.setCellWidget(row, 0, track_label)
|
|
|
|
|
|
# Artist(s) with ellipsis label
|
|
|
artists = ", ".join(track.artists)
|
|
|
artist_label = EllipsisLabel(artists)
|
|
|
artist_label.setFont(QFont("SF Pro Text", 11))
|
|
|
artist_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
|
|
self.track_table.setCellWidget(row, 1, artist_label)
|
|
|
|
|
|
# Album with ellipsis label
|
|
|
album_label = EllipsisLabel(track.album)
|
|
|
album_label.setFont(QFont("SF Pro Text", 11))
|
|
|
album_label.setStyleSheet("color: #ffffff; background: transparent; border: none;")
|
|
|
self.track_table.setCellWidget(row, 2, album_label)
|
|
|
|
|
|
# Duration with standard item (doesn't need scrolling)
|
|
|
duration = self.format_duration(track.duration_ms)
|
|
|
duration_item = QTableWidgetItem(duration)
|
|
|
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
|
duration_item.setFont(QFont("SF Mono", 10))
|
|
|
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
|
|
|
self.is_selected = False
|
|
|
self.download_modal = None
|
|
|
|
|
|
# Sync state tracking
|
|
|
self.is_syncing = False
|
|
|
self.sync_total_tracks = 0
|
|
|
self.sync_matched_tracks = 0
|
|
|
self.sync_failed_tracks = 0
|
|
|
self.sync_status_widget = None
|
|
|
|
|
|
# Selection state tracking
|
|
|
self._pending_click = False
|
|
|
|
|
|
self.setup_ui()
|
|
|
|
|
|
def on_checkbox_clicked(self):
|
|
|
"""Handle direct checkbox click - use same debounced logic"""
|
|
|
print(f"Direct checkbox click for {self.name}")
|
|
|
self.toggle_selection()
|
|
|
|
|
|
def update_selection_style(self):
|
|
|
"""Update visual style based on selection state"""
|
|
|
if self.is_selected:
|
|
|
self.setStyleSheet("""
|
|
|
PlaylistItem {
|
|
|
background: rgba(29, 185, 84, 0.1);
|
|
|
border-radius: 8px;
|
|
|
border: 2px solid #1db954;
|
|
|
}
|
|
|
PlaylistItem:hover {
|
|
|
background: rgba(29, 185, 84, 0.15);
|
|
|
border: 2px solid #1ed760;
|
|
|
}
|
|
|
""")
|
|
|
else:
|
|
|
self.setStyleSheet("""
|
|
|
PlaylistItem {
|
|
|
background: #282828;
|
|
|
border-radius: 8px;
|
|
|
border: 1px solid #404040;
|
|
|
}
|
|
|
PlaylistItem:hover {
|
|
|
background: #333333;
|
|
|
border: 1px solid #1db954;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
|
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
|
|
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
|
|
|
|
layout = QHBoxLayout(self)
|
|
|
layout.setContentsMargins(20, 15, 20, 15)
|
|
|
layout.setSpacing(15)
|
|
|
|
|
|
self.checkbox = QCheckBox()
|
|
|
self.checkbox.clicked.connect(self.on_checkbox_clicked)
|
|
|
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 = QVBoxLayout()
|
|
|
content_layout.setSpacing(5)
|
|
|
|
|
|
name_label = QLabel(self.name)
|
|
|
name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
|
|
name_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
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;")
|
|
|
|
|
|
# **FIX**: Renamed this to `sync_status_label` to avoid conflicts
|
|
|
self.sync_status_label = QLabel(self.sync_status)
|
|
|
self.sync_status_label.setFont(QFont("Arial", 10))
|
|
|
if "Synced" in self.sync_status:
|
|
|
self.sync_status_label.setStyleSheet("color: #1db954;")
|
|
|
elif self.sync_status == "Needs Sync":
|
|
|
self.sync_status_label.setStyleSheet("color: #ffa500;")
|
|
|
else:
|
|
|
self.sync_status_label.setStyleSheet("color: #e22134;")
|
|
|
|
|
|
info_layout.addWidget(track_label)
|
|
|
info_layout.addWidget(self.sync_status_label)
|
|
|
info_layout.addStretch()
|
|
|
|
|
|
content_layout.addWidget(name_label)
|
|
|
content_layout.addLayout(info_layout)
|
|
|
|
|
|
self.action_btn = QPushButton("Sync / Download")
|
|
|
self.action_btn.setFixedSize(120, 30)
|
|
|
self.action_btn.clicked.connect(self.on_action_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;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# **FIX**: Renamed this to `operation_status_button` to avoid conflicts
|
|
|
self.operation_status_button = QPushButton()
|
|
|
self.operation_status_button.setFixedSize(120, 30)
|
|
|
self.operation_status_button.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;
|
|
|
|
|
|
}
|
|
|
""")
|
|
|
self.operation_status_button.clicked.connect(self.on_status_clicked)
|
|
|
self.operation_status_button.hide()
|
|
|
|
|
|
self.download_modal = None
|
|
|
self.sync_status_widget = self.create_compact_sync_status()
|
|
|
|
|
|
layout.addWidget(self.checkbox)
|
|
|
layout.addLayout(content_layout)
|
|
|
layout.addStretch()
|
|
|
layout.addWidget(self.sync_status_widget)
|
|
|
layout.addWidget(self.action_btn)
|
|
|
layout.addWidget(self.operation_status_button)
|
|
|
|
|
|
self.installEventFilter(self)
|
|
|
for child in self.findChildren(QWidget):
|
|
|
if child != self.action_btn and child != self.operation_status_button:
|
|
|
child.installEventFilter(self)
|
|
|
|
|
|
def eventFilter(self, source, event):
|
|
|
"""Filter events to handle clicks anywhere on the item"""
|
|
|
if event.type() == event.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
|
|
|
# **FIX**: Updated to check for the correctly named button
|
|
|
if source == self.action_btn or source == self.operation_status_button:
|
|
|
return False
|
|
|
|
|
|
print(f"Event filter caught click on {source} in playlist {self.name}")
|
|
|
self.toggle_selection()
|
|
|
return True
|
|
|
|
|
|
return super().eventFilter(source, event)
|
|
|
|
|
|
def toggle_selection(self):
|
|
|
"""Toggle the selection state of this playlist item immediately"""
|
|
|
if self._pending_click:
|
|
|
return
|
|
|
|
|
|
self._pending_click = True
|
|
|
|
|
|
sync_page = self
|
|
|
while sync_page and not isinstance(sync_page, SyncPage):
|
|
|
sync_page = sync_page.parent()
|
|
|
|
|
|
if sync_page and self.playlist and self.playlist.id:
|
|
|
currently_selected = self.playlist.id in sync_page.selected_playlists
|
|
|
sync_page.toggle_playlist_selection(self.playlist.id)
|
|
|
new_state = self.playlist.id in sync_page.selected_playlists
|
|
|
self.is_selected = new_state
|
|
|
|
|
|
self.checkbox.blockSignals(True)
|
|
|
self.checkbox.setChecked(new_state)
|
|
|
self.checkbox.blockSignals(False)
|
|
|
|
|
|
self.update_selection_style()
|
|
|
print(f"Processed click for {self.name}: {currently_selected} -> {new_state}")
|
|
|
else:
|
|
|
print(f"Could not process click for {self.name} - missing sync page or playlist ID")
|
|
|
|
|
|
QTimer.singleShot(25, lambda: setattr(self, '_pending_click', False))
|
|
|
|
|
|
def mousePressEvent(self, event):
|
|
|
"""Handle direct clicks on the playlist item background"""
|
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
|
print(f"Direct click on playlist item: {self.name}")
|
|
|
self.toggle_selection()
|
|
|
super().mousePressEvent(event)
|
|
|
|
|
|
def sync_selection_state(self):
|
|
|
"""Synchronize selection state with parent SyncPage (call when needed)"""
|
|
|
sync_page = self
|
|
|
while sync_page and not isinstance(sync_page, SyncPage):
|
|
|
sync_page = sync_page.parent()
|
|
|
|
|
|
if sync_page and self.playlist and self.playlist.id:
|
|
|
actual_selected = self.playlist.id in sync_page.selected_playlists
|
|
|
|
|
|
if self.is_selected != actual_selected:
|
|
|
print(f"Syncing state for {self.name}: {self.is_selected} -> {actual_selected}")
|
|
|
self.is_selected = actual_selected
|
|
|
|
|
|
self.checkbox.blockSignals(True)
|
|
|
self.checkbox.setChecked(actual_selected)
|
|
|
self.checkbox.blockSignals(False)
|
|
|
|
|
|
self.update_selection_style()
|
|
|
|
|
|
def create_compact_sync_status(self):
|
|
|
"""Create compact sync status display for playlist item"""
|
|
|
sync_status = QFrame()
|
|
|
sync_status.setFixedHeight(36)
|
|
|
sync_status.setStyleSheet("""
|
|
|
QFrame {
|
|
|
background: rgba(29, 185, 84, 0.1);
|
|
|
border: 1px solid rgba(29, 185, 84, 0.3);
|
|
|
border-radius: 15px;
|
|
|
}
|
|
|
""")
|
|
|
sync_status.hide()
|
|
|
|
|
|
layout = QHBoxLayout(sync_status)
|
|
|
layout.setContentsMargins(8, 6, 8, 6)
|
|
|
layout.setSpacing(6)
|
|
|
|
|
|
self.item_total_tracks_label = QLabel("♪ 0")
|
|
|
self.item_total_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
|
|
self.item_total_tracks_label.setStyleSheet("color: #ffa500; background: transparent; border: none;")
|
|
|
|
|
|
self.item_matched_tracks_label = QLabel("✓ 0")
|
|
|
self.item_matched_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
|
|
self.item_matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
|
|
|
|
|
self.item_failed_tracks_label = QLabel("✗ 0")
|
|
|
self.item_failed_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
|
|
self.item_failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;")
|
|
|
|
|
|
self.item_percentage_label = QLabel("0%")
|
|
|
self.item_percentage_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Bold))
|
|
|
self.item_percentage_label.setStyleSheet("color: #1db954; background: transparent; border: none;")
|
|
|
|
|
|
layout.addWidget(self.item_total_tracks_label)
|
|
|
|
|
|
item_sep1 = QLabel("/")
|
|
|
item_sep1.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
|
|
item_sep1.setStyleSheet("color: #666666; background: transparent; border: none;")
|
|
|
layout.addWidget(item_sep1)
|
|
|
|
|
|
layout.addWidget(self.item_matched_tracks_label)
|
|
|
|
|
|
item_sep2 = QLabel("/")
|
|
|
item_sep2.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
|
|
item_sep2.setStyleSheet("color: #666666; background: transparent; border: none;")
|
|
|
layout.addWidget(item_sep2)
|
|
|
|
|
|
layout.addWidget(self.item_failed_tracks_label)
|
|
|
|
|
|
item_sep3 = QLabel("/")
|
|
|
item_sep3.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium))
|
|
|
item_sep3.setStyleSheet("color: #666666; background: transparent; border: none;")
|
|
|
layout.addWidget(item_sep3)
|
|
|
|
|
|
layout.addWidget(self.item_percentage_label)
|
|
|
|
|
|
return sync_status
|
|
|
|
|
|
def update_sync_status(self, total_tracks=0, matched_tracks=0, failed_tracks=0):
|
|
|
"""Update sync status display for playlist item"""
|
|
|
self.sync_total_tracks = total_tracks
|
|
|
self.sync_matched_tracks = matched_tracks
|
|
|
self.sync_failed_tracks = failed_tracks
|
|
|
|
|
|
if self.sync_status_widget and hasattr(self, 'item_total_tracks_label'):
|
|
|
self.item_total_tracks_label.setText(f"♪ {total_tracks}")
|
|
|
self.item_matched_tracks_label.setText(f"✓ {matched_tracks}")
|
|
|
self.item_failed_tracks_label.setText(f"✗ {failed_tracks}")
|
|
|
|
|
|
if total_tracks > 0:
|
|
|
processed_tracks = matched_tracks + failed_tracks
|
|
|
percentage = int((processed_tracks / total_tracks) * 100)
|
|
|
self.item_percentage_label.setText(f"{percentage}%")
|
|
|
else:
|
|
|
self.item_percentage_label.setText("0%")
|
|
|
|
|
|
if total_tracks > 0 or self.is_syncing:
|
|
|
self.sync_status_widget.show()
|
|
|
else:
|
|
|
self.sync_status_widget.hide()
|
|
|
|
|
|
def show_operation_status(self, status_text="View Progress"):
|
|
|
"""Changes the button to show an operation is in progress."""
|
|
|
# **FIX**: Updated to use the correctly named button
|
|
|
self.operation_status_button.setText(status_text)
|
|
|
self.operation_status_button.show()
|
|
|
self.action_btn.hide()
|
|
|
|
|
|
def hide_operation_status(self):
|
|
|
"""Resets the button to its default state."""
|
|
|
# **FIX**: Updated to use the correctly named button
|
|
|
self.operation_status_button.hide()
|
|
|
self.action_btn.show()
|
|
|
|
|
|
def on_action_clicked(self):
|
|
|
"""If a download is in progress, show the modal. Otherwise, open details."""
|
|
|
if self.download_modal:
|
|
|
self.download_modal.show()
|
|
|
self.download_modal.activateWindow()
|
|
|
else:
|
|
|
self.view_details_clicked.emit(self.playlist)
|
|
|
|
|
|
def update_operation_status(self, status_text):
|
|
|
"""Update the operation status text"""
|
|
|
# **FIX**: Updated to use the correctly named button
|
|
|
self.operation_status_button.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):
|
|
|
# Signals for dashboard activity tracking
|
|
|
sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
|
|
|
database_updated_externally = pyqtSignal()
|
|
|
|
|
|
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.sync_statuses = load_sync_status()
|
|
|
self.current_playlists = []
|
|
|
self.playlist_loader = None
|
|
|
self.active_download_processes = {}
|
|
|
# Track cache for performance
|
|
|
self.track_cache = {} # playlist_id -> tracks
|
|
|
|
|
|
# Sync worker management
|
|
|
self.active_sync_workers = {} # playlist_id -> SyncWorker (for individual modal syncs)
|
|
|
self.sequential_sync_worker = None # Current sequential sync worker
|
|
|
|
|
|
# Selection tracking
|
|
|
self.selected_playlists = set() # Set of selected playlist IDs
|
|
|
self.sequential_sync_queue = [] # Queue for sequential syncing
|
|
|
self.is_sequential_syncing = False
|
|
|
|
|
|
# Thread pool for async operations (like downloads.py)
|
|
|
self.thread_pool = QThreadPool()
|
|
|
self.thread_pool.setMaxThreadCount(3) # Limit concurrent Spotify API calls
|
|
|
|
|
|
# Initialize Plex scan manager
|
|
|
self.scan_manager = None
|
|
|
if self.plex_client:
|
|
|
self.scan_manager = PlexScanManager(self.plex_client, delay_seconds=60)
|
|
|
# Add automatic incremental database update after Plex scan completion
|
|
|
self.scan_manager.add_scan_completion_callback(self._on_plex_scan_completed)
|
|
|
|
|
|
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 set_toast_manager(self, toast_manager):
|
|
|
"""Set the toast manager for showing notifications"""
|
|
|
self.toast_manager = toast_manager
|
|
|
|
|
|
def _on_plex_scan_completed(self):
|
|
|
"""Callback triggered when Plex scan completes - start automatic incremental database update"""
|
|
|
try:
|
|
|
# Import here to avoid circular imports
|
|
|
from database import get_database
|
|
|
from core.database_update_worker import DatabaseUpdateWorker
|
|
|
|
|
|
# Check if we should run incremental update
|
|
|
if not self.plex_client or not self.plex_client.is_connected():
|
|
|
logger.debug("Plex not connected - skipping automatic database update")
|
|
|
return
|
|
|
|
|
|
# Check if database has a previous full refresh
|
|
|
database = get_database()
|
|
|
last_full_refresh = database.get_last_full_refresh()
|
|
|
if not last_full_refresh:
|
|
|
logger.info("No previous full refresh found - skipping automatic incremental update")
|
|
|
return
|
|
|
|
|
|
# Check if database has sufficient content
|
|
|
try:
|
|
|
stats = database.get_database_info()
|
|
|
track_count = stats.get('tracks', 0)
|
|
|
|
|
|
if track_count < 100:
|
|
|
logger.info(f"Database has only {track_count} tracks - skipping automatic incremental update")
|
|
|
return
|
|
|
except Exception as e:
|
|
|
logger.warning(f"Could not check database stats - skipping automatic update: {e}")
|
|
|
return
|
|
|
|
|
|
# All conditions met - start incremental update
|
|
|
logger.info("🎵 Starting automatic incremental database update after Plex scan")
|
|
|
self._start_automatic_incremental_update()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error in Plex scan completion callback: {e}")
|
|
|
|
|
|
def _start_automatic_incremental_update(self):
|
|
|
"""Start the automatic incremental database update"""
|
|
|
try:
|
|
|
from core.database_update_worker import DatabaseUpdateWorker
|
|
|
|
|
|
# Avoid duplicate workers
|
|
|
if hasattr(self, '_auto_database_worker') and self._auto_database_worker and self._auto_database_worker.isRunning():
|
|
|
logger.debug("Automatic database update already running")
|
|
|
return
|
|
|
|
|
|
# Create worker for incremental update only
|
|
|
self._auto_database_worker = DatabaseUpdateWorker(
|
|
|
self.plex_client,
|
|
|
"database/music_library.db",
|
|
|
full_refresh=False # Always incremental for automatic updates
|
|
|
)
|
|
|
|
|
|
# Connect completion signal to log result
|
|
|
self._auto_database_worker.finished.connect(self._on_auto_update_finished)
|
|
|
self._auto_database_worker.error.connect(self._on_auto_update_error)
|
|
|
|
|
|
# Start the update
|
|
|
self._auto_database_worker.start()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error starting automatic incremental update: {e}")
|
|
|
|
|
|
def _on_auto_update_finished(self, total_artists, total_albums, total_tracks, successful, failed):
|
|
|
"""Handle completion of automatic database update"""
|
|
|
try:
|
|
|
if successful > 0:
|
|
|
logger.info(f"✅ Automatic database update completed: {successful} items processed successfully")
|
|
|
else:
|
|
|
logger.info("💡 Automatic database update completed - no new content found")
|
|
|
|
|
|
# Emit the signal to notify the dashboard to refresh its statistics
|
|
|
self.database_updated_externally.emit()
|
|
|
logger.info("📊 Emitted signal to refresh dashboard database statistics after auto update")
|
|
|
|
|
|
# Clean up the worker
|
|
|
if hasattr(self, '_auto_database_worker'):
|
|
|
self._auto_database_worker.deleteLater()
|
|
|
delattr(self, '_auto_database_worker')
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error handling automatic update completion: {e}")
|
|
|
|
|
|
def _on_auto_update_error(self, error_message):
|
|
|
"""Handle error in automatic database update"""
|
|
|
logger.warning(f"Automatic database update encountered an error: {error_message}")
|
|
|
|
|
|
# Clean up the worker
|
|
|
if hasattr(self, '_auto_database_worker'):
|
|
|
self._auto_database_worker.deleteLater()
|
|
|
delattr(self, '_auto_database_worker')
|
|
|
|
|
|
def _update_and_save_sync_status(self, playlist_id, result, snapshot_id):
|
|
|
"""Updates the sync status for a given playlist and saves to file."""
|
|
|
# THE FIX: This function will now run even if there are failed tracks,
|
|
|
# ensuring the sync time and snapshot_id are always recorded.
|
|
|
playlist_obj = next((p for p in self.current_playlists if p.id == playlist_id), None)
|
|
|
|
|
|
if playlist_obj:
|
|
|
now = datetime.now()
|
|
|
self.sync_statuses[playlist_id] = {
|
|
|
'name': playlist_obj.name,
|
|
|
'owner': playlist_obj.owner,
|
|
|
'snapshot_id': snapshot_id,
|
|
|
'last_synced': now.isoformat()
|
|
|
}
|
|
|
save_sync_status(self.sync_statuses)
|
|
|
|
|
|
# This now targets the correct label for real-time UI updates
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
if playlist_item and hasattr(playlist_item, 'sync_status_label'):
|
|
|
new_status_text = f"Synced: {now.strftime('%b %d, %H:%M')}"
|
|
|
playlist_item.sync_status_label.setText(new_status_text)
|
|
|
playlist_item.sync_status_label.setStyleSheet("color: #1db954;")
|
|
|
|
|
|
def is_playlist_syncing(self, playlist_id):
|
|
|
"""Check if a playlist is currently syncing"""
|
|
|
return playlist_id in self.active_sync_workers
|
|
|
|
|
|
def get_playlist_sync_worker(self, playlist_id):
|
|
|
"""Get the sync worker for a playlist if it exists"""
|
|
|
return self.active_sync_workers.get(playlist_id)
|
|
|
|
|
|
def start_playlist_sync(self, playlist):
|
|
|
"""Start sync for a playlist (called from modal)"""
|
|
|
if playlist.id in self.active_sync_workers:
|
|
|
# Already syncing
|
|
|
return False
|
|
|
|
|
|
# Create sync service if not available
|
|
|
if not hasattr(self, 'sync_service'):
|
|
|
from services.sync_service import PlaylistSyncService
|
|
|
self.sync_service = PlaylistSyncService(
|
|
|
self.spotify_client,
|
|
|
self.plex_client,
|
|
|
self.soulseek_client
|
|
|
)
|
|
|
|
|
|
# Create sync worker
|
|
|
sync_worker = SyncWorker(
|
|
|
playlist=playlist,
|
|
|
sync_service=self.sync_service
|
|
|
)
|
|
|
|
|
|
# Connect worker signals
|
|
|
sync_worker.signals.finished.connect(lambda result, sid: self.on_sync_finished(playlist.id, result, sid))
|
|
|
|
|
|
sync_worker.signals.error.connect(lambda error: self.on_sync_error(playlist.id, error))
|
|
|
sync_worker.signals.progress.connect(lambda progress: self.on_sync_progress(playlist.id, progress))
|
|
|
|
|
|
# Store the worker
|
|
|
self.active_sync_workers[playlist.id] = sync_worker
|
|
|
|
|
|
# Emit activity signal for sync start
|
|
|
self.sync_activity.emit("🔄", "Sync Started", f"Syncing playlist '{playlist.name}'", "Now")
|
|
|
|
|
|
# Show toast notification for sync start
|
|
|
if hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
track_count = len(playlist.tracks) if hasattr(playlist, 'tracks') else 0
|
|
|
if track_count > 0:
|
|
|
self.toast_manager.show_toast(f"Starting sync for '{playlist.name}' ({track_count} tracks)", ToastType.INFO)
|
|
|
|
|
|
# Start the worker
|
|
|
self.thread_pool.start(sync_worker)
|
|
|
|
|
|
# Update playlist item status
|
|
|
playlist_item = self.find_playlist_item_widget(playlist.id)
|
|
|
if playlist_item:
|
|
|
playlist_item.is_syncing = True
|
|
|
playlist_item.update_sync_status(len(playlist.tracks), 0, 0)
|
|
|
|
|
|
# Log start
|
|
|
if hasattr(self, 'log_area'):
|
|
|
self.log_area.append(f"🔄 Starting sync for playlist: {playlist.name}")
|
|
|
|
|
|
# Update refresh button state since we now have an active sync
|
|
|
self.update_refresh_button_state()
|
|
|
|
|
|
return True
|
|
|
|
|
|
def start_sequential_playlist_sync(self, playlist):
|
|
|
"""Start sync for a playlist as part of sequential sync (separate from individual syncs)"""
|
|
|
# Create sync service if not available
|
|
|
if not hasattr(self, 'sync_service'):
|
|
|
from services.sync_service import PlaylistSyncService
|
|
|
self.sync_service = PlaylistSyncService(
|
|
|
self.spotify_client,
|
|
|
self.plex_client,
|
|
|
self.soulseek_client
|
|
|
)
|
|
|
|
|
|
# Create sync worker for sequential sync
|
|
|
sync_worker = SyncWorker(
|
|
|
playlist=playlist,
|
|
|
sync_service=self.sync_service
|
|
|
)
|
|
|
|
|
|
# Connect worker signals for sequential sync
|
|
|
sync_worker.signals.finished.connect(lambda result, sid: self.on_sequential_sync_finished(playlist.id, result, sid))
|
|
|
sync_worker.signals.error.connect(lambda error: self.on_sequential_sync_error(playlist.id, error))
|
|
|
sync_worker.signals.progress.connect(lambda progress: self.on_sync_progress(playlist.id, progress))
|
|
|
|
|
|
# Store the sequential sync worker
|
|
|
self.sequential_sync_worker = sync_worker
|
|
|
|
|
|
# Start the worker
|
|
|
self.thread_pool.start(sync_worker)
|
|
|
|
|
|
# Update playlist item status
|
|
|
playlist_item = self.find_playlist_item_widget(playlist.id)
|
|
|
if playlist_item:
|
|
|
playlist_item.is_syncing = True
|
|
|
playlist_item.update_sync_status(len(playlist.tracks), 0, 0)
|
|
|
|
|
|
# Log start
|
|
|
if hasattr(self, 'log_area'):
|
|
|
self.log_area.append(f"🔄 Starting sequential sync for playlist: {playlist.name}")
|
|
|
|
|
|
# Show toast notification for sequential sync start
|
|
|
if hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
track_count = len(playlist.tracks) if hasattr(playlist, 'tracks') else 0
|
|
|
if track_count > 0:
|
|
|
self.toast_manager.show_toast(f"Starting sequential sync for '{playlist.name}' ({track_count} tracks)", ToastType.INFO)
|
|
|
|
|
|
return True
|
|
|
|
|
|
def toggle_playlist_selection(self, playlist_id):
|
|
|
"""Toggle selection state of a playlist"""
|
|
|
if playlist_id in self.selected_playlists:
|
|
|
self.selected_playlists.remove(playlist_id)
|
|
|
print(f"Deselected playlist: {playlist_id}")
|
|
|
else:
|
|
|
self.selected_playlists.add(playlist_id)
|
|
|
print(f"Selected playlist: {playlist_id}")
|
|
|
|
|
|
print(f"Total selected: {len(self.selected_playlists)}")
|
|
|
self.update_selection_ui()
|
|
|
|
|
|
def update_selection_ui(self):
|
|
|
"""Update the selection info label and button state"""
|
|
|
selected_count = len(self.selected_playlists)
|
|
|
|
|
|
print(f"Updating UI with {selected_count} selected playlists, sequential syncing: {self.is_sequential_syncing}, individual syncs: {len(self.active_sync_workers)}")
|
|
|
|
|
|
if selected_count == 0:
|
|
|
self.selection_info.setText("Select playlists to sync")
|
|
|
self.start_sync_btn.setEnabled(False)
|
|
|
print("Button disabled - no selection")
|
|
|
elif self.has_active_operations():
|
|
|
# Don't change button state during any active operations
|
|
|
print(f"Active operations in progress - keeping button as is")
|
|
|
elif selected_count == 1:
|
|
|
self.selection_info.setText("1 playlist selected")
|
|
|
self.start_sync_btn.setEnabled(True)
|
|
|
print("Button enabled - 1 playlist")
|
|
|
else:
|
|
|
self.selection_info.setText(f"{selected_count} playlists selected")
|
|
|
self.start_sync_btn.setEnabled(True)
|
|
|
print(f"Button enabled - {selected_count} playlists")
|
|
|
|
|
|
def start_selected_playlist_sync(self):
|
|
|
"""Start syncing all selected playlists sequentially"""
|
|
|
if not self.selected_playlists or self.is_sequential_syncing:
|
|
|
return
|
|
|
|
|
|
# Don't allow sequential sync if individual syncs are already running
|
|
|
if self.active_sync_workers:
|
|
|
print(f"DEBUG: Cannot start sequential sync - {len(self.active_sync_workers)} individual syncs are running")
|
|
|
return
|
|
|
|
|
|
# Get selected playlist objects
|
|
|
selected_playlist_objects = []
|
|
|
for playlist_item in self.get_all_playlist_items():
|
|
|
if playlist_item.playlist.id in self.selected_playlists:
|
|
|
selected_playlist_objects.append(playlist_item.playlist)
|
|
|
|
|
|
if not selected_playlist_objects:
|
|
|
return
|
|
|
|
|
|
# Start sequential sync
|
|
|
self.sequential_sync_queue = selected_playlist_objects.copy()
|
|
|
self.is_sequential_syncing = True
|
|
|
self.start_sync_btn.setText("Syncing...")
|
|
|
self.start_sync_btn.setEnabled(False)
|
|
|
|
|
|
# Disable refresh button during sequential sync
|
|
|
self.update_refresh_button_state()
|
|
|
|
|
|
# Start first sync
|
|
|
self.process_next_in_sync_queue()
|
|
|
|
|
|
def process_next_in_sync_queue(self):
|
|
|
"""Process the next playlist in the sequential sync queue."""
|
|
|
print(f"DEBUG: process_next_in_sync_queue - queue length: {len(self.sequential_sync_queue)}, is_syncing: {self.is_sequential_syncing}")
|
|
|
|
|
|
if self.sequential_sync_queue and self.is_sequential_syncing:
|
|
|
# Get next playlist to sync
|
|
|
next_playlist = self.sequential_sync_queue.pop(0)
|
|
|
print(f"DEBUG: Starting sync for next playlist: {next_playlist.name}")
|
|
|
|
|
|
# Start sync for this playlist
|
|
|
if not self.start_sequential_playlist_sync(next_playlist):
|
|
|
# If sync failed to start, immediately process the next one
|
|
|
print("DEBUG: Sync failed to start, moving to next playlist")
|
|
|
self.process_next_in_sync_queue()
|
|
|
else:
|
|
|
# If queue is empty or sync was cancelled, call the final completion handler
|
|
|
print("DEBUG: Sequential sync queue is empty or syncing stopped - calling completion handler.")
|
|
|
self.on_sequential_sync_complete()
|
|
|
|
|
|
def on_sequential_sync_complete(self):
|
|
|
"""Handle completion of the entire sequential sync process."""
|
|
|
# Ensure this runs only once at the very end
|
|
|
if not self.is_sequential_syncing:
|
|
|
return
|
|
|
|
|
|
print("DEBUG: Sequential sync process complete. Resetting all states.")
|
|
|
self.is_sequential_syncing = False
|
|
|
self.sequential_sync_queue.clear()
|
|
|
self.sequential_sync_worker = None # Ensure worker is cleared
|
|
|
|
|
|
# Reset the button text and state authoritatively
|
|
|
self.start_sync_btn.setText("Start Sync")
|
|
|
|
|
|
# Update the entire UI based on the new, correct state
|
|
|
self.update_selection_ui()
|
|
|
self.update_refresh_button_state()
|
|
|
|
|
|
def on_sequential_sync_finished(self, playlist_id, result, snapshot_id):
|
|
|
"""Handle completion of individual playlist in sequential sync"""
|
|
|
print(f"DEBUG: Sequential sync finished for playlist {playlist_id}")
|
|
|
|
|
|
# Clear sequential sync worker
|
|
|
self.sequential_sync_worker = None
|
|
|
|
|
|
# Update playlist item status
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
if playlist_item:
|
|
|
playlist_item.is_syncing = False
|
|
|
playlist_item.update_sync_status(
|
|
|
result.total_tracks,
|
|
|
result.matched_tracks,
|
|
|
result.failed_tracks
|
|
|
)
|
|
|
|
|
|
# Hide status widget after completion with delay
|
|
|
QTimer.singleShot(3000, lambda: playlist_item.sync_status_widget.hide() if playlist_item.sync_status_widget else None)
|
|
|
|
|
|
# Update any open modals
|
|
|
self.update_open_modals_completion(playlist_id, result)
|
|
|
|
|
|
# Pass the snapshot_id to the save function
|
|
|
self._update_and_save_sync_status(playlist_id, result, snapshot_id)
|
|
|
|
|
|
# Log completion
|
|
|
if hasattr(self, 'log_area'):
|
|
|
success_rate = result.success_rate
|
|
|
msg = f"✅ Sequential 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.log_area.append(msg)
|
|
|
|
|
|
# Show toast notification for sequential sync completion
|
|
|
if hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
playlist_name = playlist_item.name if playlist_item else "Unknown Playlist"
|
|
|
if result.failed_tracks > 0:
|
|
|
self.toast_manager.show_toast(f"'{playlist_name}' sync completed: {result.matched_tracks}/{result.total_tracks} tracks, {result.failed_tracks} failed", ToastType.WARNING)
|
|
|
else:
|
|
|
self.toast_manager.show_toast(f"'{playlist_name}' sync completed: {result.matched_tracks} tracks added", ToastType.SUCCESS)
|
|
|
|
|
|
# **THE FIX**: Defer processing the next item to allow the event loop to catch up.
|
|
|
# This ensures UI updates (like the status label) are processed before moving on.
|
|
|
if self.is_sequential_syncing:
|
|
|
print(f"DEBUG: Scheduling next playlist in sequence.")
|
|
|
QTimer.singleShot(10, self.process_next_in_sync_queue)
|
|
|
|
|
|
def on_sequential_sync_error(self, playlist_id, error_msg):
|
|
|
"""Handle error in individual playlist during sequential sync"""
|
|
|
print(f"DEBUG: Sequential sync error for playlist {playlist_id}: {error_msg}")
|
|
|
|
|
|
# Clear sequential sync worker
|
|
|
self.sequential_sync_worker = None
|
|
|
|
|
|
# Update playlist item status
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
if playlist_item:
|
|
|
playlist_item.is_syncing = False
|
|
|
if playlist_item.sync_status_widget:
|
|
|
playlist_item.sync_status_widget.hide()
|
|
|
|
|
|
# Update any open modals
|
|
|
self.update_open_modals_error(playlist_id, error_msg)
|
|
|
|
|
|
# Log error
|
|
|
if hasattr(self, 'log_area'):
|
|
|
self.log_area.append(f"❌ Sequential sync failed: {error_msg}")
|
|
|
|
|
|
# Show toast notification for sequential sync error
|
|
|
if hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
playlist_name = playlist_item.name if playlist_id else "Unknown Playlist"
|
|
|
self.toast_manager.show_toast(f"Sequential sync failed for '{playlist_name}': {error_msg}", ToastType.ERROR)
|
|
|
|
|
|
# **THE FIX**: Defer processing the next item to allow the event loop to catch up.
|
|
|
if self.is_sequential_syncing:
|
|
|
print(f"DEBUG: Scheduling next playlist in sequence despite error.")
|
|
|
QTimer.singleShot(10, self.process_next_in_sync_queue)
|
|
|
|
|
|
def get_all_playlist_items(self):
|
|
|
"""Get all PlaylistItem widgets from the playlist layout"""
|
|
|
playlist_items = []
|
|
|
for i in range(self.playlist_layout.count()):
|
|
|
item = self.playlist_layout.itemAt(i)
|
|
|
widget = item.widget()
|
|
|
if isinstance(widget, PlaylistItem):
|
|
|
playlist_items.append(widget)
|
|
|
return playlist_items
|
|
|
|
|
|
def cancel_playlist_sync(self, playlist_id):
|
|
|
"""Cancel sync for a playlist"""
|
|
|
if playlist_id in self.active_sync_workers:
|
|
|
worker = self.active_sync_workers[playlist_id]
|
|
|
worker.cancel()
|
|
|
|
|
|
# Remove from active workers
|
|
|
del self.active_sync_workers[playlist_id]
|
|
|
|
|
|
# Update playlist item status
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
if playlist_item:
|
|
|
playlist_item.is_syncing = False
|
|
|
if playlist_item.sync_status_widget:
|
|
|
playlist_item.sync_status_widget.hide()
|
|
|
|
|
|
# Log cancellation
|
|
|
if hasattr(self, 'log_area'):
|
|
|
self.log_area.append(f"🚫 Sync cancelled for playlist")
|
|
|
|
|
|
return True
|
|
|
return False
|
|
|
|
|
|
def on_sync_progress(self, playlist_id, progress):
|
|
|
"""Handle sync progress updates"""
|
|
|
# Update playlist item status
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
if playlist_item:
|
|
|
playlist_item.update_sync_status(
|
|
|
progress.total_tracks,
|
|
|
progress.matched_tracks,
|
|
|
progress.failed_tracks
|
|
|
)
|
|
|
|
|
|
# Update any open modal for this playlist
|
|
|
self.update_open_modals_progress(playlist_id, progress)
|
|
|
|
|
|
def on_sync_finished(self, playlist_id, result, snapshot_id):
|
|
|
"""Handle sync completion"""
|
|
|
# Remove from active workers
|
|
|
if playlist_id in self.active_sync_workers:
|
|
|
del self.active_sync_workers[playlist_id]
|
|
|
|
|
|
# Update playlist item status
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
if playlist_item:
|
|
|
playlist_item.is_syncing = False
|
|
|
playlist_item.update_sync_status(
|
|
|
result.total_tracks,
|
|
|
result.matched_tracks,
|
|
|
result.failed_tracks
|
|
|
)
|
|
|
|
|
|
# Hide status widget after completion with delay
|
|
|
QTimer.singleShot(3000, lambda: playlist_item.sync_status_widget.hide() if playlist_item.sync_status_widget else None)
|
|
|
|
|
|
# Update any open modals
|
|
|
self.update_open_modals_completion(playlist_id, result)
|
|
|
|
|
|
# Pass the snapshot_id to the save function
|
|
|
self._update_and_save_sync_status(playlist_id, result, snapshot_id)
|
|
|
|
|
|
# Emit activity signal for sync completion
|
|
|
playlist_name = playlist_item.name if playlist_item else "Unknown Playlist"
|
|
|
success_msg = f"Completed: {result.matched_tracks}/{result.total_tracks} tracks"
|
|
|
self.sync_activity.emit("✅", "Sync Complete", f"'{playlist_name}' - {success_msg}", "Now")
|
|
|
|
|
|
# Show toast notification for sync completion
|
|
|
if hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
if result.failed_tracks > 0:
|
|
|
self.toast_manager.show_toast(f"Sync completed: {result.matched_tracks}/{result.total_tracks} tracks added, {result.failed_tracks} failed", ToastType.WARNING)
|
|
|
else:
|
|
|
self.toast_manager.show_toast(f"Sync completed: {result.matched_tracks} tracks added to queue", ToastType.SUCCESS)
|
|
|
|
|
|
# Continue sequential sync if in progress
|
|
|
if self.is_sequential_syncing:
|
|
|
print(f"DEBUG: Sync finished for {playlist_id}, continuing sequential sync")
|
|
|
self.process_next_in_sync_queue()
|
|
|
else:
|
|
|
print(f"DEBUG: Sync finished for {playlist_id}, not in sequential sync mode")
|
|
|
|
|
|
# Update refresh button state since a sync completed
|
|
|
self.update_refresh_button_state()
|
|
|
|
|
|
# Log completion
|
|
|
if hasattr(self, '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.log_area.append(msg)
|
|
|
|
|
|
def on_sync_error(self, playlist_id, error_msg):
|
|
|
"""Handle sync error"""
|
|
|
# Remove from active workers
|
|
|
if playlist_id in self.active_sync_workers:
|
|
|
del self.active_sync_workers[playlist_id]
|
|
|
|
|
|
# Update playlist item status
|
|
|
playlist_item = self.find_playlist_item_widget(playlist_id)
|
|
|
if playlist_item:
|
|
|
playlist_item.is_syncing = False
|
|
|
if playlist_item.sync_status_widget:
|
|
|
playlist_item.sync_status_widget.hide()
|
|
|
|
|
|
# Update any open modals
|
|
|
self.update_open_modals_error(playlist_id, error_msg)
|
|
|
|
|
|
# Emit activity signal for sync error
|
|
|
playlist_name = playlist_item.name if playlist_item else "Unknown Playlist"
|
|
|
self.sync_activity.emit("❌", "Sync Failed", f"'{playlist_name}' - {error_msg}", "Now")
|
|
|
|
|
|
# Show toast notification for sync error
|
|
|
if hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
self.toast_manager.show_toast(f"Sync failed for '{playlist_name}': {error_msg}", ToastType.ERROR)
|
|
|
|
|
|
# Continue sequential sync if in progress (even on error)
|
|
|
if self.is_sequential_syncing:
|
|
|
self.process_next_in_sync_queue()
|
|
|
|
|
|
# Update refresh button state since a sync completed (with error)
|
|
|
self.update_refresh_button_state()
|
|
|
|
|
|
# Log error
|
|
|
if hasattr(self, 'log_area'):
|
|
|
self.log_area.append(f"❌ Sync failed: {error_msg}")
|
|
|
|
|
|
def update_open_modals_progress(self, playlist_id, progress):
|
|
|
"""Update any open PlaylistDetailsModal for this playlist with sync progress"""
|
|
|
# Find all open PlaylistDetailsModal instances for this playlist
|
|
|
# We need to check all top-level widgets that might be modals
|
|
|
from PyQt6.QtWidgets import QApplication
|
|
|
for widget in QApplication.topLevelWidgets():
|
|
|
if (isinstance(widget, PlaylistDetailsModal) and
|
|
|
hasattr(widget, 'playlist') and
|
|
|
widget.playlist.id == playlist_id and
|
|
|
widget.isVisible()):
|
|
|
# Update the modal's progress display
|
|
|
widget.on_sync_progress(playlist_id, progress)
|
|
|
|
|
|
def update_open_modals_completion(self, playlist_id, result):
|
|
|
"""Update any open PlaylistDetailsModal for this playlist with sync completion"""
|
|
|
from PyQt6.QtWidgets import QApplication
|
|
|
for widget in QApplication.topLevelWidgets():
|
|
|
if (isinstance(widget, PlaylistDetailsModal) and
|
|
|
hasattr(widget, 'playlist') and
|
|
|
widget.playlist.id == playlist_id and
|
|
|
widget.isVisible()):
|
|
|
# Update the modal's completion display
|
|
|
widget.on_sync_finished(playlist_id, result)
|
|
|
|
|
|
def update_open_modals_error(self, playlist_id, error_msg):
|
|
|
"""Update any open PlaylistDetailsModal for this playlist with sync error"""
|
|
|
from PyQt6.QtWidgets import QApplication
|
|
|
for widget in QApplication.topLevelWidgets():
|
|
|
if (isinstance(widget, PlaylistDetailsModal) and
|
|
|
hasattr(widget, 'playlist') and
|
|
|
widget.playlist.id == playlist_id and
|
|
|
widget.isVisible()):
|
|
|
# Update the modal's error display
|
|
|
widget.on_sync_error(playlist_id, error_msg)
|
|
|
|
|
|
# Add these three methods inside the SyncPage class
|
|
|
def find_playlist_item_widget(self, playlist_id):
|
|
|
"""Finds the PlaylistItem widget in the UI that corresponds to a given playlist ID."""
|
|
|
for i in range(self.playlist_layout.count()):
|
|
|
item = self.playlist_layout.itemAt(i)
|
|
|
widget = item.widget()
|
|
|
if isinstance(widget, PlaylistItem) and widget.playlist.id == playlist_id:
|
|
|
return widget
|
|
|
return None
|
|
|
|
|
|
def on_download_process_started(self, playlist_id, playlist_item_widget):
|
|
|
"""Disables refresh button and updates the playlist item UI."""
|
|
|
print(f"Download process started for playlist: {playlist_id}. Disabling refresh.")
|
|
|
self.active_download_processes[playlist_id] = playlist_item_widget
|
|
|
playlist_item_widget.show_operation_status()
|
|
|
|
|
|
# Use centralized refresh button management
|
|
|
self.update_refresh_button_state()
|
|
|
# --- FIX: Connect the finished signal from the modal ---
|
|
|
# This ensures that when the modal is finished (or cancelled), the cleanup function is called.
|
|
|
if playlist_item_widget.download_modal:
|
|
|
playlist_item_widget.download_modal.process_finished.connect(
|
|
|
lambda: self.on_download_process_finished(playlist_id)
|
|
|
)
|
|
|
|
|
|
def on_download_process_finished(self, playlist_id):
|
|
|
"""Re-enables refresh button if no other downloads are active."""
|
|
|
print(f"Download process finished or cancelled for playlist: {playlist_id}.")
|
|
|
|
|
|
# Clear download modal reference even if not in active_download_processes
|
|
|
playlist_item_widget = None
|
|
|
if playlist_id in self.active_download_processes:
|
|
|
playlist_item_widget = self.active_download_processes.pop(playlist_id)
|
|
|
else:
|
|
|
# Find the playlist item widget even if not in active processes
|
|
|
playlist_item_widget = self.find_playlist_item_widget(playlist_id)
|
|
|
|
|
|
# --- FIX: Reset the UI state of the playlist item ---
|
|
|
if playlist_item_widget:
|
|
|
playlist_item_widget.download_modal = None
|
|
|
playlist_item_widget.hide_operation_status()
|
|
|
|
|
|
if not self.active_download_processes:
|
|
|
print("All download processes finished. Re-enabling refresh.")
|
|
|
# Use centralized refresh button management
|
|
|
self.update_refresh_button_state()
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
# Clear selection state when auto-loading
|
|
|
self.selected_playlists.clear()
|
|
|
self.update_selection_ui()
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
# Selection info label
|
|
|
self.selection_info = QLabel("Select playlists to sync")
|
|
|
self.selection_info.setFont(QFont("Arial", 12))
|
|
|
self.selection_info.setStyleSheet("color: #b3b3b3;")
|
|
|
self.selection_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
|
|
# Sync button (initially disabled)
|
|
|
self.start_sync_btn = QPushButton("Start Sync")
|
|
|
self.start_sync_btn.setFixedHeight(45)
|
|
|
self.start_sync_btn.setEnabled(False) # Disabled by default
|
|
|
self.start_sync_btn.clicked.connect(self.start_selected_playlist_sync)
|
|
|
self.start_sync_btn.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #1db954;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #000000;
|
|
|
font-size: 14px;
|
|
|
font-weight: bold;
|
|
|
}
|
|
|
QPushButton:hover:enabled {
|
|
|
background: #1ed760;
|
|
|
}
|
|
|
QPushButton:pressed:enabled {
|
|
|
background: #1aa34a;
|
|
|
}
|
|
|
QPushButton:disabled {
|
|
|
background: #404040;
|
|
|
color: #666666;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
actions_layout.addWidget(self.selection_info)
|
|
|
actions_layout.addWidget(self.start_sync_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
|
|
|
|
|
|
# Override append method to limit to 200 lines
|
|
|
original_append = self.log_area.append
|
|
|
def limited_append(text):
|
|
|
original_append(text)
|
|
|
# Keep only last 200 lines
|
|
|
text_content = self.log_area.toPlainText()
|
|
|
lines = text_content.split('\n')
|
|
|
if len(lines) > 200:
|
|
|
trimmed_lines = lines[-200:]
|
|
|
self.log_area.setPlainText('\n'.join(trimmed_lines))
|
|
|
# Move cursor to end
|
|
|
cursor = self.log_area.textCursor()
|
|
|
cursor.movePosition(cursor.MoveOperation.End)
|
|
|
self.log_area.setTextCursor(cursor)
|
|
|
self.log_area.append = limited_append
|
|
|
|
|
|
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()
|
|
|
|
|
|
# Clear selection state when refreshing
|
|
|
self.selected_playlists.clear()
|
|
|
self.update_selection_ui()
|
|
|
|
|
|
# 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"""
|
|
|
sync_info = self.sync_statuses.get(playlist.id)
|
|
|
sync_status = "Never Synced"
|
|
|
if sync_info and 'last_synced' in sync_info:
|
|
|
# Defensively get snapshot_id from both the current playlist object and the stored data
|
|
|
current_snapshot_id = getattr(playlist, 'snapshot_id', None)
|
|
|
stored_snapshot_id = sync_info.get('snapshot_id')
|
|
|
|
|
|
# If we have both IDs, we can check for changes. Otherwise, we can't be sure.
|
|
|
if current_snapshot_id and stored_snapshot_id and current_snapshot_id != stored_snapshot_id:
|
|
|
sync_status = "Needs Sync"
|
|
|
else:
|
|
|
try:
|
|
|
last_synced_dt = datetime.fromisoformat(sync_info['last_synced'])
|
|
|
sync_status = f"Synced: {last_synced_dt.strftime('%b %d, %H:%M')}"
|
|
|
except (ValueError, KeyError):
|
|
|
sync_status = "Synced (legacy)"
|
|
|
item = PlaylistItem(playlist.name, playlist.total_tracks, sync_status, playlist, self)
|
|
|
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 has_active_operations(self):
|
|
|
"""Check if any sync or download operations are currently active"""
|
|
|
has_downloads = bool(self.active_download_processes)
|
|
|
has_individual_syncs = bool(self.active_sync_workers)
|
|
|
has_sequential_sync = self.is_sequential_syncing or self.sequential_sync_worker is not None
|
|
|
|
|
|
print(f"DEBUG: Active operations check - downloads: {has_downloads}, individual syncs: {has_individual_syncs}, sequential: {has_sequential_sync}")
|
|
|
return has_downloads or has_individual_syncs or has_sequential_sync
|
|
|
|
|
|
def update_refresh_button_state(self):
|
|
|
"""Update refresh button state based on active operations"""
|
|
|
if self.has_active_operations():
|
|
|
if self.is_sequential_syncing:
|
|
|
self.disable_refresh_button("Sequential Sync")
|
|
|
elif self.active_sync_workers:
|
|
|
self.disable_refresh_button("Sync")
|
|
|
elif self.active_download_processes:
|
|
|
self.disable_refresh_button("Download")
|
|
|
else:
|
|
|
self.enable_refresh_button()
|
|
|
|
|
|
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, self) # Set parent for placeholders too
|
|
|
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 ManualMatchModal(QDialog):
|
|
|
"""
|
|
|
A completely redesigned modal for manually searching and resolving a failed track download.
|
|
|
Features controlled searching, cancellation, and a UI consistent with the main application.
|
|
|
This version dynamically updates its track list from the parent modal and has a live-updating count.
|
|
|
"""
|
|
|
track_resolved = pyqtSignal(object)
|
|
|
|
|
|
def __init__(self, parent_modal):
|
|
|
"""Initializes the modal with a direct reference to the parent."""
|
|
|
super().__init__(parent_modal)
|
|
|
self.parent_modal = parent_modal
|
|
|
|
|
|
# Handle different parent modal types with flexible attribute access
|
|
|
try:
|
|
|
# Try the standard structure first (DownloadMissingTracksModal, DownloadMissingAlbumTracksModal)
|
|
|
self.soulseek_client = parent_modal.parent_page.soulseek_client
|
|
|
self.downloads_page = parent_modal.downloads_page
|
|
|
except AttributeError:
|
|
|
# Fallback for dashboard wishlist modal or other structures
|
|
|
try:
|
|
|
# Dashboard wishlist modal might have soulseek_client directly
|
|
|
self.soulseek_client = getattr(parent_modal, 'soulseek_client', None)
|
|
|
self.downloads_page = getattr(parent_modal, 'downloads_page', None)
|
|
|
|
|
|
# If still not found, try to get from parent widget hierarchy
|
|
|
if not self.soulseek_client:
|
|
|
current_widget = parent_modal.parent()
|
|
|
while current_widget and not self.soulseek_client:
|
|
|
self.soulseek_client = getattr(current_widget, 'soulseek_client', None)
|
|
|
self.downloads_page = getattr(current_widget, 'downloads_page', None)
|
|
|
current_widget = current_widget.parent()
|
|
|
|
|
|
except AttributeError:
|
|
|
pass
|
|
|
|
|
|
# Validate we have the required clients
|
|
|
if not self.soulseek_client:
|
|
|
raise RuntimeError("Could not find soulseek_client in parent modal or widget hierarchy")
|
|
|
|
|
|
self.failed_tracks = []
|
|
|
self.current_track_index = 0
|
|
|
self.current_track_info = None
|
|
|
self.search_worker = None
|
|
|
self.thread_pool = QThreadPool.globalInstance()
|
|
|
|
|
|
# Timer to delay automatic search
|
|
|
self.search_delay_timer = QTimer(self)
|
|
|
self.search_delay_timer.setSingleShot(True)
|
|
|
self.search_delay_timer.timeout.connect(self.perform_manual_search)
|
|
|
|
|
|
# Timer to periodically check for updates to the total failed track count
|
|
|
self.live_update_timer = QTimer(self)
|
|
|
self.live_update_timer.timeout.connect(self._check_and_update_count)
|
|
|
self.live_update_timer.start(1000) # Check every second
|
|
|
|
|
|
self.setup_ui()
|
|
|
self.load_current_track()
|
|
|
|
|
|
def setup_ui(self):
|
|
|
"""Set up the visually redesigned UI."""
|
|
|
self.setWindowTitle("Manual Track Correction")
|
|
|
self.setMinimumSize(900, 700)
|
|
|
self.setStyleSheet("""
|
|
|
QDialog { background-color: #1e1e1e; color: #ffffff; }
|
|
|
QLabel { color: #ffffff; font-size: 14px; }
|
|
|
QLineEdit {
|
|
|
background-color: #3a3a3a;
|
|
|
border: 1px solid #555555;
|
|
|
border-radius: 6px;
|
|
|
padding: 10px;
|
|
|
color: #ffffff;
|
|
|
font-size: 13px;
|
|
|
}
|
|
|
QScrollArea { border: none; background-color: #2d2d2d; }
|
|
|
QWidget#resultsWidget { background-color: #2d2d2d; }
|
|
|
""")
|
|
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
|
main_layout.setContentsMargins(20, 20, 20, 20)
|
|
|
main_layout.setSpacing(15)
|
|
|
|
|
|
# --- Failed Track Info Card ---
|
|
|
info_frame = QFrame()
|
|
|
info_frame.setStyleSheet("""
|
|
|
QFrame {
|
|
|
background-color: #2d2d2d;
|
|
|
border: 1px solid #444444;
|
|
|
border-radius: 8px;
|
|
|
padding: 15px;
|
|
|
}
|
|
|
""")
|
|
|
info_layout = QVBoxLayout(info_frame)
|
|
|
self.info_label = QLabel("Loading track...")
|
|
|
self.info_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
|
self.info_label.setStyleSheet("color: #ffc107;") # Amber color for warning
|
|
|
self.info_label.setWordWrap(True)
|
|
|
info_layout.addWidget(self.info_label)
|
|
|
main_layout.addWidget(info_frame)
|
|
|
|
|
|
# --- Search Input and Controls ---
|
|
|
search_frame = QFrame()
|
|
|
search_layout = QHBoxLayout(search_frame)
|
|
|
search_layout.setContentsMargins(0,0,0,0)
|
|
|
search_layout.setSpacing(10)
|
|
|
|
|
|
self.search_input = QLineEdit()
|
|
|
self.search_input.setPlaceholderText("Enter a new search query or use the suggestion...")
|
|
|
self.search_input.returnPressed.connect(self.perform_manual_search)
|
|
|
|
|
|
self.search_btn = QPushButton("Search")
|
|
|
self.search_btn.clicked.connect(self.perform_manual_search)
|
|
|
self.search_btn.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background-color: #1db954; color: #000000; border: none;
|
|
|
border-radius: 6px; font-size: 13px; font-weight: bold;
|
|
|
padding: 10px 20px;
|
|
|
}
|
|
|
QPushButton:hover { background-color: #1ed760; }
|
|
|
""")
|
|
|
|
|
|
self.cancel_search_btn = QPushButton("Cancel")
|
|
|
self.cancel_search_btn.clicked.connect(self.cancel_current_search)
|
|
|
self.cancel_search_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; }
|
|
|
""")
|
|
|
self.cancel_search_btn.hide() # Initially hidden
|
|
|
|
|
|
search_layout.addWidget(self.search_input, 1)
|
|
|
search_layout.addWidget(self.search_btn)
|
|
|
search_layout.addWidget(self.cancel_search_btn)
|
|
|
main_layout.addWidget(search_frame)
|
|
|
|
|
|
# --- Search Results Area ---
|
|
|
self.results_scroll = QScrollArea()
|
|
|
self.results_scroll.setWidgetResizable(True)
|
|
|
self.results_widget = QWidget()
|
|
|
self.results_widget.setObjectName("resultsWidget")
|
|
|
self.results_layout = QVBoxLayout(self.results_widget)
|
|
|
self.results_layout.setSpacing(8)
|
|
|
self.results_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
|
self.results_scroll.setWidget(self.results_widget)
|
|
|
main_layout.addWidget(self.results_scroll, 1)
|
|
|
|
|
|
# --- Navigation and Close Buttons ---
|
|
|
nav_layout = QHBoxLayout()
|
|
|
self.prev_btn = QPushButton("← Previous")
|
|
|
self.prev_btn.clicked.connect(self.load_previous_track)
|
|
|
|
|
|
self.track_position_label = QLabel()
|
|
|
self.track_position_label.setStyleSheet("color: #ffffff; font-weight: bold;")
|
|
|
|
|
|
self.next_btn = QPushButton("Next →")
|
|
|
self.next_btn.clicked.connect(self.load_next_track)
|
|
|
|
|
|
self.close_btn = QPushButton("Close")
|
|
|
self.close_btn.setStyleSheet("""
|
|
|
QPushButton { background-color: #616161; color: #ffffff; }
|
|
|
QPushButton:hover { background-color: #757575; }
|
|
|
""")
|
|
|
self.close_btn.clicked.connect(self.reject)
|
|
|
|
|
|
for btn in [self.prev_btn, self.next_btn, self.close_btn]:
|
|
|
btn.setFixedSize(120, 40)
|
|
|
|
|
|
nav_layout.addWidget(self.prev_btn)
|
|
|
nav_layout.addStretch()
|
|
|
nav_layout.addWidget(self.track_position_label)
|
|
|
nav_layout.addStretch()
|
|
|
nav_layout.addWidget(self.next_btn)
|
|
|
nav_layout.addWidget(self.close_btn)
|
|
|
|
|
|
main_layout.addLayout(nav_layout)
|
|
|
|
|
|
def _check_and_update_count(self):
|
|
|
"""
|
|
|
Periodically called by a timer to check if the total number of failed
|
|
|
tracks has changed and updates the navigation label if needed.
|
|
|
"""
|
|
|
try:
|
|
|
live_total = len(self.parent_modal.permanently_failed_tracks)
|
|
|
|
|
|
# Extract the current total from the label text "Track X of Y"
|
|
|
parts = self.track_position_label.text().split(' of ')
|
|
|
if len(parts) == 2:
|
|
|
displayed_total = int(parts[1])
|
|
|
if live_total != displayed_total:
|
|
|
# If the total has changed, refresh the navigation state
|
|
|
self.update_navigation_state()
|
|
|
else:
|
|
|
# If the label is not in the expected format, update it anyway
|
|
|
self.update_navigation_state()
|
|
|
except (ValueError, IndexError):
|
|
|
# Handle cases where the label text is not yet set or in an unexpected format
|
|
|
self.update_navigation_state()
|
|
|
|
|
|
|
|
|
def _update_track_list(self):
|
|
|
"""
|
|
|
Syncs the modal's internal track list with the parent's live list,
|
|
|
preserving the user's current position.
|
|
|
"""
|
|
|
live_failed_tracks = self.parent_modal.permanently_failed_tracks
|
|
|
old_count = len(self.failed_tracks) if hasattr(self, 'failed_tracks') else 0
|
|
|
|
|
|
current_track_id = None
|
|
|
if self.current_track_info:
|
|
|
current_track_id = self.current_track_info.get('download_index')
|
|
|
|
|
|
self.failed_tracks = list(live_failed_tracks)
|
|
|
new_count = len(self.failed_tracks)
|
|
|
|
|
|
print(f"🔄 Track list sync: {old_count} → {new_count} failed tracks, current_track_id={current_track_id}")
|
|
|
|
|
|
if not self.failed_tracks:
|
|
|
print("⚠️ No failed tracks remaining")
|
|
|
return
|
|
|
|
|
|
new_index = -1
|
|
|
if current_track_id is not None:
|
|
|
for i, track in enumerate(self.failed_tracks):
|
|
|
if track.get('download_index') == current_track_id:
|
|
|
new_index = i
|
|
|
break
|
|
|
|
|
|
old_index = self.current_track_index
|
|
|
if new_index != -1:
|
|
|
self.current_track_index = new_index
|
|
|
else:
|
|
|
# If the current track was resolved, stay at the same index
|
|
|
# but check bounds against the new list length.
|
|
|
if self.current_track_index >= len(self.failed_tracks):
|
|
|
self.current_track_index = len(self.failed_tracks) - 1
|
|
|
|
|
|
if self.current_track_index < 0:
|
|
|
self.current_track_index = 0
|
|
|
|
|
|
if old_index != self.current_track_index:
|
|
|
print(f"📍 Index changed: {old_index} → {self.current_track_index}")
|
|
|
|
|
|
def load_current_track(self):
|
|
|
"""Loads the current failed track's info and intelligently triggers a search."""
|
|
|
self.cancel_current_search()
|
|
|
self.clear_results()
|
|
|
|
|
|
# Only sync track list if we don't already have the current track loaded
|
|
|
# This prevents the index from being reset when navigating
|
|
|
if not hasattr(self, 'failed_tracks') or len(self.failed_tracks) == 0:
|
|
|
self._update_track_list()
|
|
|
|
|
|
if not self.failed_tracks:
|
|
|
QMessageBox.information(self, "Complete", "All failed tracks have been addressed.")
|
|
|
self.accept()
|
|
|
return
|
|
|
|
|
|
# Ensure current_track_index is still valid after any potential sync
|
|
|
if self.current_track_index >= len(self.failed_tracks):
|
|
|
self.current_track_index = len(self.failed_tracks) - 1
|
|
|
if self.current_track_index < 0:
|
|
|
self.current_track_index = 0
|
|
|
|
|
|
self.update_navigation_state()
|
|
|
|
|
|
self.current_track_info = self.failed_tracks[self.current_track_index]
|
|
|
spotify_track = self.current_track_info['spotify_track']
|
|
|
artist = spotify_track.artists[0] if spotify_track.artists else "Unknown"
|
|
|
|
|
|
print(f"📍 Loading track at index {self.current_track_index}: {spotify_track.name} by {artist}")
|
|
|
|
|
|
# Use the original track name for the info label
|
|
|
self.info_label.setText(f"Could not find: <b>{spotify_track.name}</b><br>by {artist}")
|
|
|
|
|
|
# Use the ORIGINAL, UNCLEANED track name for the initial search query
|
|
|
self.search_input.setText(f"{artist} {spotify_track.name}")
|
|
|
|
|
|
self.search_delay_timer.start(1000)
|
|
|
|
|
|
def load_next_track(self):
|
|
|
"""Navigate to the next failed track."""
|
|
|
# Sync the track list first to handle any resolved tracks
|
|
|
self._update_track_list()
|
|
|
|
|
|
print(f"🔄 Next clicked: current_index={self.current_track_index}, failed_tracks_count={len(self.failed_tracks)}")
|
|
|
|
|
|
if self.current_track_index < len(self.failed_tracks) - 1:
|
|
|
self.current_track_index += 1
|
|
|
print(f"✅ Moving to next track: new_index={self.current_track_index}")
|
|
|
self.load_current_track()
|
|
|
else:
|
|
|
print(f"⚠️ Already at last track (index {self.current_track_index} of {len(self.failed_tracks)})")
|
|
|
|
|
|
def load_previous_track(self):
|
|
|
"""Navigate to the previous failed track."""
|
|
|
# Sync the track list first to handle any resolved tracks
|
|
|
self._update_track_list()
|
|
|
|
|
|
if self.current_track_index > 0:
|
|
|
self.current_track_index -= 1
|
|
|
self.load_current_track()
|
|
|
|
|
|
def update_navigation_state(self):
|
|
|
"""Update the 'Track X of Y' label and enable/disable nav buttons."""
|
|
|
# Use the internal synchronized list for consistency
|
|
|
total_tracks = len(self.failed_tracks)
|
|
|
|
|
|
# Ensure current_track_index is valid even if list shrinks
|
|
|
if self.current_track_index >= total_tracks:
|
|
|
self.current_track_index = max(0, total_tracks - 1)
|
|
|
|
|
|
current_pos = self.current_track_index + 1 if total_tracks > 0 else 0
|
|
|
|
|
|
self.track_position_label.setText(f"Track {current_pos} of {total_tracks}")
|
|
|
self.prev_btn.setEnabled(self.current_track_index > 0)
|
|
|
self.next_btn.setEnabled(self.current_track_index < total_tracks - 1)
|
|
|
|
|
|
def perform_manual_search(self):
|
|
|
"""Initiates a search for the current query, cancelling any existing search."""
|
|
|
self.search_delay_timer.stop()
|
|
|
self.cancel_current_search()
|
|
|
|
|
|
query = self.search_input.text().strip()
|
|
|
if not query: return
|
|
|
|
|
|
self.clear_results()
|
|
|
self.results_layout.addWidget(QLabel(f"<h3>Searching for '{query}'...</h3>"))
|
|
|
self.search_btn.hide()
|
|
|
self.cancel_search_btn.show()
|
|
|
|
|
|
self.search_worker = self.SearchWorker(self.soulseek_client, query)
|
|
|
self.search_worker.signals.completed.connect(self.on_manual_search_completed)
|
|
|
self.search_worker.signals.failed.connect(self.on_manual_search_failed)
|
|
|
self.thread_pool.start(self.search_worker)
|
|
|
|
|
|
def cancel_current_search(self):
|
|
|
"""Stops the currently running search worker."""
|
|
|
if self.search_worker:
|
|
|
self.search_worker.cancel()
|
|
|
self.search_worker = None
|
|
|
self.search_btn.show()
|
|
|
self.cancel_search_btn.hide()
|
|
|
|
|
|
def on_manual_search_completed(self, results):
|
|
|
"""Handles successful search results."""
|
|
|
if not self.search_worker or self.search_worker.is_cancelled:
|
|
|
return
|
|
|
|
|
|
self.cancel_current_search()
|
|
|
self.clear_results()
|
|
|
|
|
|
if not results:
|
|
|
self.results_layout.addWidget(QLabel("<h3>No results found for this query.</h3>"))
|
|
|
return
|
|
|
|
|
|
for result in results:
|
|
|
self.results_layout.addWidget(self.create_result_widget(result))
|
|
|
|
|
|
def on_manual_search_failed(self, error):
|
|
|
"""Handles a failed search attempt."""
|
|
|
if not self.search_worker or self.search_worker.is_cancelled:
|
|
|
return
|
|
|
|
|
|
self.cancel_current_search()
|
|
|
self.clear_results()
|
|
|
self.results_layout.addWidget(QLabel(f"<h3>Search failed:</h3><p>{error}</p>"))
|
|
|
|
|
|
def create_result_widget(self, result: TrackResult):
|
|
|
"""Creates a styled widget for a single search result."""
|
|
|
widget = QFrame()
|
|
|
widget.setStyleSheet("""
|
|
|
QFrame {
|
|
|
background-color: #3a3a3a;
|
|
|
border: 1px solid #555555;
|
|
|
border-radius: 6px;
|
|
|
padding: 10px;
|
|
|
}
|
|
|
QFrame:hover {
|
|
|
border: 1px solid #1db954;
|
|
|
}
|
|
|
""")
|
|
|
layout = QHBoxLayout(widget)
|
|
|
|
|
|
path_parts = result.filename.replace('\\', '/').split('/')
|
|
|
filename = path_parts[-1]
|
|
|
path_structure = '/'.join(path_parts[:-1])
|
|
|
|
|
|
size_kb = result.size // 1024
|
|
|
info_text = (f"<b>{filename}</b><br>"
|
|
|
f"<i style='color:#aaaaaa;'>{path_structure}</i><br>"
|
|
|
f"Quality: <b>{result.quality.upper()}</b>, "
|
|
|
f"Size: <b>{size_kb:,} KB</b>, "
|
|
|
f"User: <b>{result.username}</b>")
|
|
|
info_label = QLabel(info_text)
|
|
|
info_label.setWordWrap(True)
|
|
|
|
|
|
select_btn = QPushButton("Select")
|
|
|
select_btn.setFixedWidth(100)
|
|
|
select_btn.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background-color: #1db954; color: #000000;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background-color: #1ed760;
|
|
|
}
|
|
|
""")
|
|
|
select_btn.clicked.connect(lambda: self.on_selection_made(result))
|
|
|
|
|
|
layout.addWidget(info_label, 1)
|
|
|
layout.addWidget(select_btn)
|
|
|
return widget
|
|
|
|
|
|
def on_selection_made(self, slskd_result):
|
|
|
"""
|
|
|
Handles user selecting a track. The parent modal removes the track from the
|
|
|
live list, and this modal will sync with that change on the next load.
|
|
|
"""
|
|
|
print(f"Manual selection made: {slskd_result.filename}")
|
|
|
|
|
|
self.parent_modal.start_validated_download_parallel(
|
|
|
slskd_result,
|
|
|
self.current_track_info['spotify_track'],
|
|
|
self.current_track_info['track_index'],
|
|
|
self.current_track_info['table_index'],
|
|
|
self.current_track_info['download_index']
|
|
|
)
|
|
|
|
|
|
self.track_resolved.emit(self.current_track_info)
|
|
|
|
|
|
# Auto-advance to the next failed track after successful selection
|
|
|
# Use a small delay to allow the parent modal to update the failed tracks list
|
|
|
QTimer.singleShot(100, self._advance_to_next_track_after_resolution)
|
|
|
|
|
|
def _advance_to_next_track_after_resolution(self):
|
|
|
"""
|
|
|
Advances to the next failed track after a successful manual resolution.
|
|
|
If no more tracks remain, closes the modal with a success message.
|
|
|
"""
|
|
|
# Sync the track list to reflect the resolved track being removed
|
|
|
self._update_track_list()
|
|
|
|
|
|
if not self.failed_tracks:
|
|
|
# No more failed tracks - show success and close
|
|
|
QMessageBox.information(self, "Complete", "All failed tracks have been resolved! 🎉")
|
|
|
self.accept()
|
|
|
return
|
|
|
|
|
|
# Check if we need to adjust the current index after removal
|
|
|
if self.current_track_index >= len(self.failed_tracks):
|
|
|
self.current_track_index = len(self.failed_tracks) - 1
|
|
|
|
|
|
# Load the next track (which might be at the same index if current was removed)
|
|
|
print(f"🔄 Auto-advancing after resolution: index {self.current_track_index} of {len(self.failed_tracks)} remaining")
|
|
|
self.load_current_track()
|
|
|
|
|
|
def clear_results(self):
|
|
|
"""Removes all widgets from the results layout."""
|
|
|
while self.results_layout.count():
|
|
|
child = self.results_layout.takeAt(0)
|
|
|
if child.widget():
|
|
|
child.widget().deleteLater()
|
|
|
|
|
|
def closeEvent(self, event):
|
|
|
"""Ensures any running search is cancelled when the modal is closed."""
|
|
|
self.cancel_current_search()
|
|
|
self.search_delay_timer.stop()
|
|
|
self.live_update_timer.stop() # Stop the live update timer
|
|
|
super().closeEvent(event)
|
|
|
|
|
|
# --- Inner classes for self-contained search worker ---
|
|
|
class SearchWorkerSignals(QObject):
|
|
|
completed = pyqtSignal(list)
|
|
|
failed = pyqtSignal(str)
|
|
|
|
|
|
class SearchWorker(QRunnable):
|
|
|
def __init__(self, soulseek_client, query):
|
|
|
super().__init__()
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.query = query
|
|
|
self.signals = ManualMatchModal.SearchWorkerSignals()
|
|
|
self.is_cancelled = False
|
|
|
|
|
|
def cancel(self):
|
|
|
self.is_cancelled = True
|
|
|
|
|
|
def run(self):
|
|
|
if self.is_cancelled:
|
|
|
return
|
|
|
|
|
|
loop = None
|
|
|
try:
|
|
|
import asyncio
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
|
search_result = loop.run_until_complete(self.soulseek_client.search(self.query))
|
|
|
|
|
|
if self.is_cancelled:
|
|
|
return
|
|
|
|
|
|
if isinstance(search_result, tuple) and len(search_result) >= 1:
|
|
|
results_list = search_result[0] if search_result[0] else []
|
|
|
else:
|
|
|
results_list = []
|
|
|
|
|
|
self.signals.completed.emit(results_list)
|
|
|
|
|
|
except Exception as e:
|
|
|
if not self.is_cancelled:
|
|
|
self.signals.failed.emit(str(e))
|
|
|
finally:
|
|
|
if loop:
|
|
|
loop.close()
|
|
|
|
|
|
|
|
|
class DownloadMissingTracksModal(QDialog):
|
|
|
"""Enhanced modal for downloading missing tracks with live progress tracking"""
|
|
|
process_finished = pyqtSignal()
|
|
|
def __init__(self, playlist, playlist_item, parent_page, downloads_page):
|
|
|
super().__init__(parent_page)
|
|
|
self.playlist = playlist
|
|
|
self.playlist_item = playlist_item
|
|
|
self.parent_page = parent_page
|
|
|
self.parent_sync_page = parent_page # Reference to sync page for scan manager
|
|
|
self.downloads_page = downloads_page
|
|
|
self.matching_engine = MusicMatchingEngine()
|
|
|
self.wishlist_service = get_wishlist_service()
|
|
|
|
|
|
# State tracking
|
|
|
self.total_tracks = len(playlist.tracks)
|
|
|
self.matched_tracks_count = 0
|
|
|
self.tracks_to_download_count = 0
|
|
|
self.downloaded_tracks_count = 0
|
|
|
self.analysis_complete = False
|
|
|
|
|
|
# --- FIX: Initialize attributes to prevent crash on close ---
|
|
|
self.download_in_progress = False
|
|
|
self.cancel_requested = False
|
|
|
|
|
|
self.permanently_failed_tracks = []
|
|
|
|
|
|
print(f"📊 Total tracks: {self.total_tracks}")
|
|
|
|
|
|
# Track analysis results
|
|
|
self.analysis_results = []
|
|
|
self.missing_tracks = []
|
|
|
|
|
|
# Worker tracking
|
|
|
self.active_workers = []
|
|
|
self.fallback_pools = []
|
|
|
|
|
|
# Status Polling
|
|
|
self.download_status_pool = QThreadPool()
|
|
|
self.download_status_pool.setMaxThreadCount(1)
|
|
|
self._is_status_update_running = False
|
|
|
|
|
|
self.download_status_timer = QTimer(self)
|
|
|
self.download_status_timer.timeout.connect(self.poll_all_download_statuses)
|
|
|
self.download_status_timer.start(2000)
|
|
|
|
|
|
self.active_downloads = []
|
|
|
|
|
|
print("🎨 Setting up UI...")
|
|
|
self.setup_ui()
|
|
|
print("✅ Modal initialization complete")
|
|
|
|
|
|
def generate_smart_search_queries(self, artist_name, track_name):
|
|
|
"""
|
|
|
Generate smart search query variations with album-in-title detection.
|
|
|
Enhanced version with fallback strategies.
|
|
|
"""
|
|
|
# Create a mock spotify track object for the matching engine
|
|
|
class MockSpotifyTrack:
|
|
|
def __init__(self, name, artists, album=None):
|
|
|
self.name = name
|
|
|
self.artists = artists if isinstance(artists, list) else [artists] if artists else []
|
|
|
self.album = album
|
|
|
|
|
|
# Try to get album information from the track context if available
|
|
|
# In sync context, we might not always have album info, but try to extract it
|
|
|
album_title = None
|
|
|
# If track_name contains potential album info, we'll let the detection handle it
|
|
|
|
|
|
mock_track = MockSpotifyTrack(track_name, [artist_name] if artist_name else [], album_title)
|
|
|
|
|
|
# Use the enhanced matching engine to generate queries
|
|
|
queries = self.matching_engine.generate_download_queries(mock_track)
|
|
|
|
|
|
# Add some legacy fallback queries for compatibility
|
|
|
legacy_queries = []
|
|
|
|
|
|
# Add first word of artist approach (legacy compatibility)
|
|
|
if artist_name:
|
|
|
artist_words = artist_name.split()
|
|
|
if artist_words:
|
|
|
first_word = artist_words[0]
|
|
|
if first_word.lower() == 'the' and len(artist_words) > 1:
|
|
|
first_word = artist_words[1]
|
|
|
|
|
|
if len(first_word) > 1:
|
|
|
legacy_queries.append(f"{track_name} {first_word}".strip())
|
|
|
|
|
|
# Add track-only query
|
|
|
legacy_queries.append(track_name.strip())
|
|
|
|
|
|
# Add traditional cleaned queries
|
|
|
import re
|
|
|
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
|
|
|
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
|
|
|
|
|
|
if cleaned_name and cleaned_name.lower() != track_name.lower():
|
|
|
legacy_queries.append(cleaned_name.strip())
|
|
|
|
|
|
# Combine enhanced queries with legacy fallbacks
|
|
|
all_queries = queries + legacy_queries
|
|
|
|
|
|
# Remove duplicates while preserving order
|
|
|
unique_queries = []
|
|
|
seen = set()
|
|
|
for query in all_queries:
|
|
|
if query and query.lower() not in seen:
|
|
|
unique_queries.append(query)
|
|
|
seen.add(query.lower())
|
|
|
|
|
|
print(f"🧠 Generated {len(unique_queries)} smart queries for '{track_name}' (enhanced with album detection)")
|
|
|
for i, query in enumerate(unique_queries):
|
|
|
print(f" {i+1}. '{query}'")
|
|
|
|
|
|
return unique_queries
|
|
|
|
|
|
def setup_ui(self):
|
|
|
"""Set up the enhanced modal UI"""
|
|
|
self.setWindowTitle(f"Download Missing Tracks - {self.playlist.name}")
|
|
|
self.resize(1200, 900)
|
|
|
self.setWindowFlags(Qt.WindowType.Window)
|
|
|
# self.setWindowFlags(Qt.WindowType.Dialog)
|
|
|
|
|
|
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)
|
|
|
|
|
|
top_section = self.create_compact_top_section()
|
|
|
main_layout.addWidget(top_section)
|
|
|
|
|
|
progress_section = self.create_progress_section()
|
|
|
main_layout.addWidget(progress_section)
|
|
|
|
|
|
table_section = self.create_track_table()
|
|
|
main_layout.addWidget(table_section, stretch=1)
|
|
|
|
|
|
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_layout = QHBoxLayout()
|
|
|
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)
|
|
|
|
|
|
dashboard_layout = QHBoxLayout()
|
|
|
dashboard_layout.setSpacing(20)
|
|
|
|
|
|
self.total_card = self.create_compact_counter_card("📀 Total", str(self.total_tracks), "#1db954")
|
|
|
self.matched_card = self.create_compact_counter_card("✅ Found", "0", "#4CAF50")
|
|
|
self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b")
|
|
|
self.downloaded_card = self.create_compact_counter_card("✅ Downloaded", "0", "#4CAF50")
|
|
|
|
|
|
dashboard_layout.addWidget(self.total_card)
|
|
|
dashboard_layout.addWidget(self.matched_card)
|
|
|
dashboard_layout.addWidget(self.download_card)
|
|
|
dashboard_layout.addWidget(self.downloaded_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_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)
|
|
|
|
|
|
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
|
|
|
elif "Downloaded" in title: self.downloaded_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)
|
|
|
|
|
|
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_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"""
|
|
|
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)
|
|
|
layout.setSpacing(10)
|
|
|
|
|
|
header_label = QLabel("📋 Track Analysis")
|
|
|
header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold))
|
|
|
header_label.setStyleSheet("color: #ffffff; padding: 5px;")
|
|
|
|
|
|
self.track_table = QTableWidget()
|
|
|
self.track_table.setColumnCount(5)
|
|
|
self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Duration", "Matched", "Status"])
|
|
|
self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
|
self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
|
|
|
self.track_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive)
|
|
|
self.track_table.setColumnWidth(2, 90)
|
|
|
self.track_table.setColumnWidth(3, 140)
|
|
|
|
|
|
self.track_table.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;
|
|
|
font-size: 12px;
|
|
|
}
|
|
|
QHeaderView::section {
|
|
|
background-color: #1db954; color: #000000; font-weight: bold;
|
|
|
font-size: 13px; padding: 12px 8px; border: none;
|
|
|
}
|
|
|
QTableWidget::item { padding: 12px 8px; border-bottom: 1px solid #4a4a4a; }
|
|
|
""")
|
|
|
|
|
|
self.track_table.setAlternatingRowColors(True)
|
|
|
self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
|
self.track_table.verticalHeader().setDefaultSectionSize(35)
|
|
|
self.track_table.verticalHeader().setVisible(False)
|
|
|
|
|
|
self.populate_track_table()
|
|
|
|
|
|
layout.addWidget(header_label)
|
|
|
layout.addWidget(self.track_table)
|
|
|
|
|
|
return table_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):
|
|
|
self.track_table.setItem(i, 0, QTableWidgetItem(track.name))
|
|
|
artist_name = track.artists[0] if track.artists else "Unknown"
|
|
|
self.track_table.setItem(i, 1, QTableWidgetItem(artist_name))
|
|
|
duration = self.format_duration(track.duration_ms)
|
|
|
duration_item = QTableWidgetItem(duration)
|
|
|
duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
self.track_table.setItem(i, 2, duration_item)
|
|
|
matched_item = QTableWidgetItem("⏳ Pending")
|
|
|
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
self.track_table.setItem(i, 3, matched_item)
|
|
|
status_item = QTableWidgetItem("—")
|
|
|
status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
self.track_table.setItem(i, 4, status_item)
|
|
|
for col in range(5):
|
|
|
self.track_table.item(i, col).setFlags(self.track_table.item(i, col).flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
|
|
|
|
def format_duration(self, duration_ms):
|
|
|
"""Convert milliseconds to MM:SS format"""
|
|
|
seconds = duration_ms // 1000
|
|
|
return f"{seconds // 60}:{seconds % 60:02d}"
|
|
|
|
|
|
def create_buttons(self):
|
|
|
"""Create improved button section"""
|
|
|
button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;")
|
|
|
layout = QHBoxLayout(button_frame)
|
|
|
layout.setSpacing(15)
|
|
|
layout.setContentsMargins(0, 10, 0, 0)
|
|
|
|
|
|
self.correct_failed_btn = QPushButton("🔧 Correct Failed Matches")
|
|
|
self.correct_failed_btn.setFixedWidth(220)
|
|
|
self.correct_failed_btn.setStyleSheet("""
|
|
|
QPushButton { background-color: #ffc107; color: #000000; border-radius: 20px; font-weight: bold; }
|
|
|
QPushButton:hover { background-color: #ffca28; }
|
|
|
""")
|
|
|
self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked)
|
|
|
self.correct_failed_btn.hide()
|
|
|
|
|
|
self.begin_search_btn = QPushButton("Begin Search")
|
|
|
self.begin_search_btn.setFixedSize(160, 40)
|
|
|
# THIS IS THE FIX: The specific stylesheet for this button is restored below
|
|
|
self.begin_search_btn.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background-color: #1db954; color: #000000; border: none;
|
|
|
border-radius: 20px; font-size: 14px; font-weight: bold;
|
|
|
}
|
|
|
QPushButton:hover { background-color: #1ed760; }
|
|
|
""")
|
|
|
self.begin_search_btn.clicked.connect(self.on_begin_search_clicked)
|
|
|
|
|
|
self.cancel_btn = QPushButton("Cancel")
|
|
|
self.cancel_btn.setFixedSize(110, 40)
|
|
|
self.cancel_btn.setStyleSheet("""
|
|
|
QPushButton { background-color: #d32f2f; color: #ffffff; border-radius: 20px;}
|
|
|
QPushButton:hover { background-color: #f44336; }
|
|
|
""")
|
|
|
self.cancel_btn.clicked.connect(self.on_cancel_clicked)
|
|
|
self.cancel_btn.hide()
|
|
|
|
|
|
self.close_btn = QPushButton("Close")
|
|
|
self.close_btn.setFixedSize(110, 40)
|
|
|
self.close_btn.setStyleSheet("""
|
|
|
QPushButton { background-color: #616161; color: #ffffff; border-radius: 20px;}
|
|
|
QPushButton:hover { background-color: #757575; }
|
|
|
""")
|
|
|
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.correct_failed_btn)
|
|
|
layout.addWidget(self.close_btn)
|
|
|
|
|
|
return button_frame
|
|
|
|
|
|
|
|
|
def on_begin_search_clicked(self):
|
|
|
"""Handle Begin Search button click - starts Plex analysis"""
|
|
|
# --- FIX: Trigger the UI change on the main page ---
|
|
|
# This is the correct point to signal that the process has started.
|
|
|
self.parent_page.on_download_process_started(self.playlist.id, self.playlist_item)
|
|
|
|
|
|
self.begin_search_btn.hide()
|
|
|
self.cancel_btn.show()
|
|
|
self.analysis_progress.setVisible(True)
|
|
|
self.analysis_progress.setMaximum(self.total_tracks)
|
|
|
self.analysis_progress.setValue(0)
|
|
|
self.download_in_progress = True # Set flag
|
|
|
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)
|
|
|
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)
|
|
|
self.active_workers.append(worker)
|
|
|
QThreadPool.globalInstance().start(worker)
|
|
|
|
|
|
def on_analysis_started(self, total_tracks):
|
|
|
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"""
|
|
|
self.analysis_progress.setValue(track_index)
|
|
|
if result.exists_in_plex:
|
|
|
matched_text = f"✅ Found ({result.confidence:.1f})"
|
|
|
self.matched_tracks_count += 1
|
|
|
self.matched_count_label.setText(str(self.matched_tracks_count))
|
|
|
else:
|
|
|
matched_text = "❌ Missing"
|
|
|
self.tracks_to_download_count += 1
|
|
|
self.download_count_label.setText(str(self.tracks_to_download_count))
|
|
|
self.track_table.setItem(track_index - 1, 3, QTableWidgetItem(matched_text))
|
|
|
|
|
|
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")
|
|
|
if self.missing_tracks:
|
|
|
# --- FIX: This line was missing, which prevented downloads from starting. ---
|
|
|
self.start_download_progress()
|
|
|
else:
|
|
|
# Handle case where no tracks are missing
|
|
|
self.download_in_progress = False # Mark process as finished
|
|
|
self.cancel_btn.hide()
|
|
|
# The modal now stays open.
|
|
|
# The process_finished signal is still emitted to unlock the main UI.
|
|
|
self.process_finished.emit()
|
|
|
QMessageBox.information(self, "Analysis Complete", "All tracks already exist in Plex! No downloads needed.")
|
|
|
|
|
|
def on_analysis_failed(self, error_message):
|
|
|
print(f"❌ Analysis failed: {error_message}")
|
|
|
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
|
|
|
self.cancel_btn.hide()
|
|
|
self.begin_search_btn.show()
|
|
|
|
|
|
def start_download_progress(self):
|
|
|
"""Start actual download progress tracking"""
|
|
|
self.download_progress.setVisible(True)
|
|
|
self.download_progress.setMaximum(len(self.missing_tracks))
|
|
|
self.download_progress.setValue(0)
|
|
|
self.start_parallel_downloads()
|
|
|
|
|
|
def start_parallel_downloads(self):
|
|
|
"""Start multiple track downloads in parallel for better performance"""
|
|
|
self.active_parallel_downloads = 0
|
|
|
self.download_queue_index = 0
|
|
|
self.failed_downloads = 0
|
|
|
self.completed_downloads = 0
|
|
|
self.successful_downloads = 0
|
|
|
self.start_next_batch_of_downloads()
|
|
|
|
|
|
def start_next_batch_of_downloads(self, max_concurrent=3):
|
|
|
"""Start the next batch of downloads up to the concurrent limit"""
|
|
|
while (self.active_parallel_downloads < max_concurrent and
|
|
|
self.download_queue_index < len(self.missing_tracks)):
|
|
|
track_result = self.missing_tracks[self.download_queue_index]
|
|
|
track = track_result.spotify_track
|
|
|
track_index = self.find_track_index_in_playlist(track)
|
|
|
self.track_table.setItem(track_index, 4, QTableWidgetItem("🔍 Searching..."))
|
|
|
self.search_and_download_track_parallel(track, self.download_queue_index, track_index)
|
|
|
self.active_parallel_downloads += 1
|
|
|
self.download_queue_index += 1
|
|
|
|
|
|
# Check if we're done: either all downloads completed OR all remaining work is done
|
|
|
downloads_complete = (self.download_queue_index >= len(self.missing_tracks) and self.active_parallel_downloads == 0)
|
|
|
all_work_complete = (self.completed_downloads >= len(self.missing_tracks))
|
|
|
|
|
|
if downloads_complete or all_work_complete:
|
|
|
self.on_all_downloads_complete()
|
|
|
|
|
|
def search_and_download_track_parallel(self, spotify_track, download_index, track_index):
|
|
|
"""Search for track and download via infrastructure path - PARALLEL VERSION"""
|
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
|
search_queries = self.generate_smart_search_queries(artist_name, spotify_track.name)
|
|
|
self.start_track_search_with_queries_parallel(spotify_track, search_queries, track_index, track_index, download_index)
|
|
|
|
|
|
def start_track_search_with_queries_parallel(self, spotify_track, search_queries, track_index, table_index, download_index):
|
|
|
"""Start track search with parallel completion handling"""
|
|
|
if not hasattr(self, 'parallel_search_tracking'):
|
|
|
self.parallel_search_tracking = {}
|
|
|
|
|
|
self.parallel_search_tracking[download_index] = {
|
|
|
'spotify_track': spotify_track, 'track_index': track_index,
|
|
|
'table_index': table_index, 'download_index': download_index,
|
|
|
'completed': False, 'used_sources': set(), 'candidates': [], 'retry_count': 0
|
|
|
}
|
|
|
self.start_search_worker_parallel(search_queries, spotify_track, track_index, table_index, 0, download_index)
|
|
|
|
|
|
def start_search_worker_parallel(self, queries, spotify_track, track_index, table_index, query_index, download_index):
|
|
|
"""Start search worker with parallel completion handling."""
|
|
|
if query_index >= len(queries):
|
|
|
self.on_parallel_track_failed(download_index, "All search strategies failed")
|
|
|
return
|
|
|
|
|
|
query = queries[query_index]
|
|
|
worker = self.ParallelSearchWorker(self.parent_page.soulseek_client, query)
|
|
|
|
|
|
worker.signals.search_completed.connect(
|
|
|
lambda r, q: self.on_search_query_completed_parallel(r, queries, spotify_track, track_index, table_index, query_index, q, download_index)
|
|
|
)
|
|
|
worker.signals.search_failed.connect(
|
|
|
lambda q, e: self.on_search_query_completed_parallel([], queries, spotify_track, track_index, table_index, query_index, q, download_index)
|
|
|
)
|
|
|
QThreadPool.globalInstance().start(worker)
|
|
|
|
|
|
def on_search_query_completed_parallel(self, results, queries, spotify_track, track_index, table_index, query_index, query, download_index):
|
|
|
"""Handle completion of a parallel search query. If it fails, trigger the next query."""
|
|
|
if hasattr(self, 'cancel_requested') and self.cancel_requested: return
|
|
|
|
|
|
valid_candidates = self.get_valid_candidates(results, spotify_track, query)
|
|
|
|
|
|
if valid_candidates:
|
|
|
# IMPORTANT: Cache the candidates for future retries
|
|
|
self.parallel_search_tracking[download_index]['candidates'] = valid_candidates
|
|
|
best_match = valid_candidates[0]
|
|
|
self.start_validated_download_parallel(best_match, spotify_track, track_index, table_index, download_index)
|
|
|
return
|
|
|
|
|
|
next_query_index = query_index + 1
|
|
|
if next_query_index < len(queries):
|
|
|
self.start_search_worker_parallel(queries, spotify_track, track_index, table_index, next_query_index, download_index)
|
|
|
else:
|
|
|
self.on_parallel_track_failed(download_index, f"No valid results after trying all {len(queries)} queries.")
|
|
|
|
|
|
def start_validated_download_parallel(self, slskd_result, spotify_metadata, track_index, table_index, download_index):
|
|
|
"""
|
|
|
Start download with validated metadata. This is used for both initial downloads
|
|
|
and for manual retries from the 'Correct Failed Matches' modal.
|
|
|
"""
|
|
|
track_info = self.parallel_search_tracking[download_index]
|
|
|
|
|
|
# --- FIX ---
|
|
|
# If this track was previously marked as 'completed' (e.g., from a failure),
|
|
|
# we need to reset its state to allow the new download attempt to be tracked correctly.
|
|
|
if track_info.get('completed', False):
|
|
|
print(f"🔄 Resetting state for manually retried track (index: {download_index}).")
|
|
|
track_info['completed'] = False
|
|
|
|
|
|
# Decrement the failed count since we are retrying it.
|
|
|
if self.failed_downloads > 0:
|
|
|
self.failed_downloads -= 1
|
|
|
|
|
|
# This download is now active again. The counter was decremented when it failed,
|
|
|
# so we increment it here to reflect its new active status.
|
|
|
self.active_parallel_downloads += 1
|
|
|
|
|
|
# The 'completed_downloads' counter was incremented when the track originally failed.
|
|
|
# We decrement it here so the overall progress calculation remains accurate when
|
|
|
# this new download attempt completes.
|
|
|
if self.completed_downloads > 0:
|
|
|
self.completed_downloads -= 1
|
|
|
|
|
|
# Add the new download source to the used sources to prevent retrying with the same user/file
|
|
|
source_key = f"{getattr(slskd_result, 'username', 'unknown')}_{slskd_result.filename}"
|
|
|
track_info['used_sources'].add(source_key)
|
|
|
|
|
|
# Update UI to show the new download has been queued
|
|
|
spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata)
|
|
|
print(f"🔧 Updating table at index {table_index} to '... Queued' for manual retry")
|
|
|
self.track_table.setItem(table_index, 4, QTableWidgetItem("... Queued"))
|
|
|
|
|
|
# Start the actual download process
|
|
|
self.start_matched_download_via_infrastructure_parallel(spotify_based_result, track_index, table_index, download_index)
|
|
|
|
|
|
def start_matched_download_via_infrastructure_parallel(self, spotify_based_result, track_index, table_index, download_index):
|
|
|
"""Start infrastructure download with parallel completion tracking"""
|
|
|
try:
|
|
|
artist = type('Artist', (), {'name': spotify_based_result.artist})()
|
|
|
download_item = self.downloads_page._start_download_with_artist(spotify_based_result, artist)
|
|
|
|
|
|
if download_item:
|
|
|
self.active_downloads.append({
|
|
|
'download_index': download_index, 'track_index': track_index,
|
|
|
'table_index': table_index, 'download_id': download_item.download_id,
|
|
|
'slskd_result': spotify_based_result, 'candidates': self.parallel_search_tracking[download_index]['candidates']
|
|
|
})
|
|
|
else:
|
|
|
self.on_parallel_track_failed(download_index, "Failed to start download")
|
|
|
except Exception as e:
|
|
|
self.on_parallel_track_failed(download_index, str(e))
|
|
|
|
|
|
def poll_all_download_statuses(self):
|
|
|
"""
|
|
|
Starts the background worker to process download statuses.
|
|
|
This version is updated to use the new worker and pass the correct data.
|
|
|
"""
|
|
|
if self._is_status_update_running or not self.active_downloads:
|
|
|
return
|
|
|
self._is_status_update_running = True
|
|
|
|
|
|
# Create a snapshot of data needed by the worker thread
|
|
|
items_to_check = []
|
|
|
for d in self.active_downloads:
|
|
|
# Ensure slskd_result exists and has a filename
|
|
|
if d.get('slskd_result') and hasattr(d['slskd_result'], 'filename'):
|
|
|
# Pass the current missing count to the worker so it can be incremented
|
|
|
items_to_check.append({
|
|
|
'widget_id': d['download_index'],
|
|
|
'download_id': d.get('download_id'), # Use .get for safety
|
|
|
'file_path': d['slskd_result'].filename,
|
|
|
'api_missing_count': d.get('api_missing_count', 0)
|
|
|
})
|
|
|
|
|
|
if not items_to_check:
|
|
|
self._is_status_update_running = False
|
|
|
return
|
|
|
|
|
|
# The new worker doesn't need the transfers directory.
|
|
|
worker = SyncStatusProcessingWorker(
|
|
|
self.parent_page.soulseek_client,
|
|
|
items_to_check
|
|
|
)
|
|
|
|
|
|
worker.signals.completed.connect(self._handle_processed_status_updates)
|
|
|
worker.signals.error.connect(lambda e: print(f"Status Worker Error: {e}"))
|
|
|
self.download_status_pool.start(worker)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_processed_status_updates(self, results):
|
|
|
"""
|
|
|
Applies status updates from the background worker and triggers retry logic.
|
|
|
This version correctly handles the payload from the new worker and adds a timeout for stuck downloads.
|
|
|
"""
|
|
|
import time
|
|
|
|
|
|
# Create a lookup for faster access to active download items
|
|
|
active_downloads_map = {d['download_index']: d for d in self.active_downloads}
|
|
|
|
|
|
for result in results:
|
|
|
download_index = result['widget_id']
|
|
|
new_status = result['status']
|
|
|
|
|
|
download_info = active_downloads_map.get(download_index)
|
|
|
if not download_info:
|
|
|
continue
|
|
|
|
|
|
# Update the main download_info object with the latest missing count from the worker
|
|
|
# This is important for the grace period logic to work across polls.
|
|
|
if 'api_missing_count' in result:
|
|
|
download_info['api_missing_count'] = result['api_missing_count']
|
|
|
|
|
|
# Update the download_id if the worker found a match by filename
|
|
|
if result.get('transfer_id') and download_info.get('download_id') != result['transfer_id']:
|
|
|
print(f"ℹ️ Corrected download ID for '{download_info['slskd_result'].filename}'")
|
|
|
download_info['download_id'] = result['transfer_id']
|
|
|
|
|
|
# Handle terminal states (completed, failed, cancelled)
|
|
|
if new_status in ['failed', 'cancelled']:
|
|
|
if download_info in self.active_downloads:
|
|
|
self.active_downloads.remove(download_info)
|
|
|
self.retry_parallel_download_with_fallback(download_info)
|
|
|
|
|
|
elif new_status == 'completed':
|
|
|
if download_info in self.active_downloads:
|
|
|
self.active_downloads.remove(download_info)
|
|
|
self.on_parallel_track_completed(download_index, success=True)
|
|
|
|
|
|
# Handle transient states (downloading, queued)
|
|
|
elif new_status == 'downloading':
|
|
|
progress = result.get('progress', 0)
|
|
|
self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem(f"⏬ Downloading ({progress}%)"))
|
|
|
|
|
|
# Reset queue timer if it exists
|
|
|
if 'queued_start_time' in download_info:
|
|
|
del download_info['queued_start_time']
|
|
|
|
|
|
# --- FIX: Add timeout for downloads stuck at 0% ---
|
|
|
# This handles cases where the API reports "InProgress" but no data is moving.
|
|
|
if progress < 1:
|
|
|
if 'downloading_start_time' not in download_info:
|
|
|
download_info['downloading_start_time'] = time.time()
|
|
|
# 90-second timeout for being stuck at 0%
|
|
|
elif time.time() - download_info['downloading_start_time'] > 90:
|
|
|
print(f"⚠️ Download for '{download_info['slskd_result'].filename}' is stuck at 0%. Cancelling and retrying.")
|
|
|
# Cancel the old download before retry
|
|
|
self.cancel_download_before_retry(download_info)
|
|
|
if download_info in self.active_downloads:
|
|
|
self.active_downloads.remove(download_info)
|
|
|
self.retry_parallel_download_with_fallback(download_info)
|
|
|
else:
|
|
|
# Progress is being made, reset the timer
|
|
|
if 'downloading_start_time' in download_info:
|
|
|
del download_info['downloading_start_time']
|
|
|
|
|
|
|
|
|
elif new_status == 'queued':
|
|
|
self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem("... Queued"))
|
|
|
# Start a timer to detect if it's stuck in queue
|
|
|
if 'queued_start_time' not in download_info:
|
|
|
download_info['queued_start_time'] = time.time()
|
|
|
elif time.time() - download_info['queued_start_time'] > 90: # 90-second timeout
|
|
|
print(f"⚠️ Download for '{download_info['slskd_result'].filename}' is stuck in queue. Cancelling and retrying.")
|
|
|
# Cancel the old download before retry
|
|
|
self.cancel_download_before_retry(download_info)
|
|
|
if download_info in self.active_downloads:
|
|
|
self.active_downloads.remove(download_info)
|
|
|
self.retry_parallel_download_with_fallback(download_info)
|
|
|
|
|
|
self._is_status_update_running = False
|
|
|
|
|
|
def cancel_download_before_retry(self, download_info):
|
|
|
"""Cancel the current download before retrying with alternative source"""
|
|
|
try:
|
|
|
slskd_result = download_info.get('slskd_result')
|
|
|
if not slskd_result:
|
|
|
print("⚠️ No slskd_result found in download_info for cancellation")
|
|
|
return
|
|
|
|
|
|
# Extract download details for cancellation
|
|
|
download_id = download_info.get('download_id')
|
|
|
username = getattr(slskd_result, 'username', None)
|
|
|
|
|
|
if download_id and username:
|
|
|
print(f"🚫 Cancelling timed-out download: {download_id} from {username}")
|
|
|
|
|
|
# Use asyncio to call the async cancel method
|
|
|
import asyncio
|
|
|
try:
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
success = loop.run_until_complete(
|
|
|
self.soulseek_client.cancel_download(download_id, username, remove=False)
|
|
|
)
|
|
|
if success:
|
|
|
print(f"✅ Successfully cancelled download {download_id}")
|
|
|
else:
|
|
|
print(f"⚠️ Failed to cancel download {download_id}")
|
|
|
finally:
|
|
|
loop.close()
|
|
|
else:
|
|
|
print(f"⚠️ Missing download_id ({download_id}) or username ({username}) for cancellation")
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"❌ Error cancelling download: {e}")
|
|
|
|
|
|
def retry_parallel_download_with_fallback(self, failed_download_info):
|
|
|
"""Retries a failed download by selecting the next-best cached candidate."""
|
|
|
download_index = failed_download_info['download_index']
|
|
|
track_info = self.parallel_search_tracking[download_index]
|
|
|
|
|
|
track_info['retry_count'] += 1
|
|
|
if track_info['retry_count'] > 2: # Max 3 attempts total (1 initial + 2 retries)
|
|
|
self.on_parallel_track_failed(download_index, "All retries failed.")
|
|
|
return
|
|
|
|
|
|
candidates = failed_download_info.get('candidates', [])
|
|
|
used_sources = track_info.get('used_sources', set())
|
|
|
|
|
|
next_candidate = None
|
|
|
for candidate in candidates:
|
|
|
source_key = f"{getattr(candidate, 'username', 'unknown')}_{candidate.filename}"
|
|
|
if source_key not in used_sources:
|
|
|
next_candidate = candidate
|
|
|
break
|
|
|
|
|
|
if not next_candidate:
|
|
|
self.on_parallel_track_failed(download_index, "No alternative sources in cache")
|
|
|
return
|
|
|
|
|
|
print(f"🔄 Retrying download {download_index + 1} with next candidate: {next_candidate.filename}")
|
|
|
self.track_table.setItem(failed_download_info['table_index'], 4, QTableWidgetItem(f"🔄 Retrying ({track_info['retry_count']})..."))
|
|
|
|
|
|
self.start_validated_download_parallel(
|
|
|
next_candidate, track_info['spotify_track'], track_info['track_index'],
|
|
|
track_info['table_index'], download_index
|
|
|
)
|
|
|
|
|
|
def on_parallel_track_completed(self, download_index, success):
|
|
|
"""Handle completion of a parallel track download"""
|
|
|
track_info = self.parallel_search_tracking.get(download_index)
|
|
|
if not track_info or track_info.get('completed', False): return
|
|
|
|
|
|
track_info['completed'] = True
|
|
|
if success:
|
|
|
print(f"🔧 Track {download_index} completed successfully - updating table index {track_info['table_index']} to '✅ Downloaded'")
|
|
|
self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("✅ Downloaded"))
|
|
|
self.downloaded_tracks_count += 1
|
|
|
# --- FIX ---
|
|
|
# Corrected the label update to use the incremented counter variable.
|
|
|
self.downloaded_count_label.setText(str(self.downloaded_tracks_count))
|
|
|
self.successful_downloads += 1
|
|
|
else:
|
|
|
print(f"🔧 Track {download_index} failed - updating table index {track_info['table_index']} to '❌ Failed'")
|
|
|
self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("❌ Failed"))
|
|
|
self.failed_downloads += 1
|
|
|
if track_info not in self.permanently_failed_tracks:
|
|
|
self.permanently_failed_tracks.append(track_info)
|
|
|
self.update_failed_matches_button()
|
|
|
|
|
|
self.completed_downloads += 1
|
|
|
self.active_parallel_downloads -= 1
|
|
|
self.download_progress.setValue(self.completed_downloads)
|
|
|
self.start_next_batch_of_downloads()
|
|
|
|
|
|
def on_parallel_track_failed(self, download_index, reason):
|
|
|
"""Handle failure of a parallel track download"""
|
|
|
print(f"❌ Parallel download {download_index + 1} failed: {reason}")
|
|
|
self.on_parallel_track_completed(download_index, False)
|
|
|
|
|
|
def update_failed_matches_button(self):
|
|
|
"""Shows, hides, and updates the counter on the 'Correct Failed Matches' button."""
|
|
|
count = len(self.permanently_failed_tracks)
|
|
|
if count > 0:
|
|
|
self.correct_failed_btn.setText(f"🔧 Correct {count} Failed Match{'es' if count > 1 else ''}")
|
|
|
self.correct_failed_btn.show()
|
|
|
else:
|
|
|
self.correct_failed_btn.hide()
|
|
|
|
|
|
def on_correct_failed_matches_clicked(self):
|
|
|
"""Opens the modal to manually correct failed downloads."""
|
|
|
if not self.permanently_failed_tracks: return
|
|
|
manual_modal = ManualMatchModal(self)
|
|
|
manual_modal.track_resolved.connect(self.on_manual_match_resolved)
|
|
|
manual_modal.exec()
|
|
|
|
|
|
def on_manual_match_resolved(self, resolved_track_info):
|
|
|
"""Handles a track being successfully resolved by the ManualMatchModal."""
|
|
|
print(f"🔧 Manual match resolved - download_index: {resolved_track_info.get('download_index')}, table_index: {resolved_track_info.get('table_index')}")
|
|
|
original_failed_track = next((t for t in self.permanently_failed_tracks if t['download_index'] == resolved_track_info['download_index']), None)
|
|
|
if original_failed_track:
|
|
|
self.permanently_failed_tracks.remove(original_failed_track)
|
|
|
print(f"✅ Removed track from permanently_failed_tracks - remaining: {len(self.permanently_failed_tracks)}")
|
|
|
|
|
|
# Update progress bar to account for manually resolved track
|
|
|
# The track was manually resolved, so we need to count it as "completed"
|
|
|
self.successful_downloads += 1
|
|
|
self.completed_downloads += 1
|
|
|
# Update the progress bar maximum to reflect the actual remaining work
|
|
|
total_remaining_work = len(self.missing_tracks) - (self.successful_downloads - len(self.permanently_failed_tracks))
|
|
|
if total_remaining_work > 0:
|
|
|
# Recalculate progress: completed work / total original work
|
|
|
progress_value = self.completed_downloads
|
|
|
self.download_progress.setValue(progress_value)
|
|
|
print(f"📊 Updated progress: {progress_value}/{self.download_progress.maximum()} (manual fix)")
|
|
|
else:
|
|
|
print("⚠️ Could not find original failed track to remove")
|
|
|
self.update_failed_matches_button()
|
|
|
|
|
|
def find_track_index_in_playlist(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.id == spotify_track.id:
|
|
|
return i
|
|
|
return None
|
|
|
|
|
|
def on_all_downloads_complete(self):
|
|
|
"""Handle completion of all downloads"""
|
|
|
self.download_in_progress = False
|
|
|
print("🎉 All downloads completed!")
|
|
|
self.cancel_btn.hide()
|
|
|
|
|
|
# The process_finished signal is still emitted to unlock the main UI.
|
|
|
self.process_finished.emit()
|
|
|
|
|
|
# Request Plex library scan if we have successful downloads
|
|
|
if self.successful_downloads > 0 and hasattr(self, 'parent_sync_page') and self.parent_sync_page.scan_manager:
|
|
|
self.parent_sync_page.scan_manager.request_scan(f"Playlist download completed ({self.successful_downloads} tracks)")
|
|
|
|
|
|
# Add permanently failed tracks to wishlist before showing completion message
|
|
|
failed_count = len(self.permanently_failed_tracks)
|
|
|
wishlist_added_count = 0
|
|
|
|
|
|
if self.permanently_failed_tracks:
|
|
|
try:
|
|
|
# Add failed tracks to wishlist
|
|
|
source_context = {
|
|
|
'playlist_name': getattr(self.playlist, 'name', 'Unknown Playlist'),
|
|
|
'playlist_id': getattr(self.playlist, 'id', None),
|
|
|
'added_from': 'sync_page_modal',
|
|
|
'timestamp': datetime.now().isoformat()
|
|
|
}
|
|
|
|
|
|
for failed_track_info in self.permanently_failed_tracks:
|
|
|
try:
|
|
|
success = self.wishlist_service.add_failed_track_from_modal(
|
|
|
track_info=failed_track_info,
|
|
|
source_type='playlist',
|
|
|
source_context=source_context
|
|
|
)
|
|
|
if success:
|
|
|
wishlist_added_count += 1
|
|
|
except Exception as e:
|
|
|
logger.error(f"Failed to add track to wishlist: {e}")
|
|
|
|
|
|
if wishlist_added_count > 0:
|
|
|
logger.info(f"Added {wishlist_added_count} failed tracks to wishlist from playlist '{self.playlist.name}'")
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error adding failed tracks to wishlist: {e}")
|
|
|
|
|
|
# Determine the final message based on success or failure.
|
|
|
if self.permanently_failed_tracks:
|
|
|
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n"
|
|
|
|
|
|
if wishlist_added_count > 0:
|
|
|
final_message += f"✨ Added {wishlist_added_count} failed track{'s' if wishlist_added_count != 1 else ''} to wishlist for automatic retry.\n\n"
|
|
|
|
|
|
final_message += "You can also manually correct failed downloads or check the wishlist on the dashboard."
|
|
|
|
|
|
# If there are failures, ensure the modal is visible and bring it to the front.
|
|
|
if self.isHidden():
|
|
|
self.show()
|
|
|
self.activateWindow()
|
|
|
self.raise_()
|
|
|
else:
|
|
|
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\nAll tracks were downloaded successfully!"
|
|
|
|
|
|
QMessageBox.information(self, "Downloads Complete", final_message)
|
|
|
|
|
|
def on_cancel_clicked(self):
|
|
|
"""Handle Cancel button - cancels operations, emits finished signal, and closes modal."""
|
|
|
# --- FIX: The full cancellation logic is now centralized here. ---
|
|
|
self.cancel_operations()
|
|
|
self.process_finished.emit() # Signal the main page to clean up and reset the button.
|
|
|
self.reject() # Close the modal.
|
|
|
|
|
|
def on_close_clicked(self):
|
|
|
# Use same logic as closeEvent - emit process_finished when no download is active
|
|
|
if self.cancel_requested or not self.download_in_progress:
|
|
|
self.cancel_operations()
|
|
|
self.process_finished.emit()
|
|
|
self.reject()
|
|
|
|
|
|
def cancel_operations(self):
|
|
|
"""Cancel any ongoing operations, including active slskd downloads."""
|
|
|
print("🛑 Cancelling all operations for this playlist...")
|
|
|
self.cancel_requested = True # Flag to stop any new workers from starting.
|
|
|
|
|
|
# --- FIX: Actively cancel downloads on the slskd server ---
|
|
|
if self.active_downloads:
|
|
|
print(f"Requesting cancellation for {len(self.active_downloads)} active download(s)...")
|
|
|
|
|
|
import asyncio
|
|
|
try:
|
|
|
loop = asyncio.get_running_loop()
|
|
|
except RuntimeError:
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
|
soulseek_client = self.parent_page.soulseek_client
|
|
|
|
|
|
# Create tasks to cancel all active downloads concurrently
|
|
|
tasks = []
|
|
|
for download_info in self.active_downloads:
|
|
|
download_id = download_info.get('download_id')
|
|
|
# Assumes the soulseek_client has a method to make raw API calls.
|
|
|
# A DELETE request is standard for cancellation in RESTful APIs like slskd's.
|
|
|
if download_id and hasattr(soulseek_client, '_make_request'):
|
|
|
tasks.append(
|
|
|
soulseek_client._make_request('DELETE', f'transfers/downloads/{download_id}')
|
|
|
)
|
|
|
|
|
|
if tasks:
|
|
|
try:
|
|
|
# Wait for all cancellation requests to be sent
|
|
|
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
|
|
print("All cancellation requests sent to slskd.")
|
|
|
except Exception as e:
|
|
|
print(f"An error occurred while sending cancellation requests: {e}")
|
|
|
|
|
|
# Cancel background workers (like the initial Plex analysis)
|
|
|
for worker in self.active_workers:
|
|
|
if hasattr(worker, 'cancel'):
|
|
|
worker.cancel()
|
|
|
self.active_workers.clear()
|
|
|
|
|
|
# Clean up any fallback thread pools
|
|
|
for pool in self.fallback_pools:
|
|
|
pool.waitForDone(1000)
|
|
|
self.fallback_pools.clear()
|
|
|
|
|
|
# Stop the status polling timer to prevent further checks
|
|
|
self.download_status_timer.stop()
|
|
|
print("🛑 Modal operations cancelled successfully.")
|
|
|
|
|
|
def closeEvent(self, event):
|
|
|
"""
|
|
|
Override close event. If the user clicks the 'X', we just hide the window.
|
|
|
The window is only truly closed (and destroyed) when the process is finished
|
|
|
or explicitly cancelled.
|
|
|
"""
|
|
|
if self.cancel_requested or not self.download_in_progress:
|
|
|
# If cancelled or finished, let it close for real.
|
|
|
self.cancel_operations()
|
|
|
self.process_finished.emit()
|
|
|
event.accept()
|
|
|
else:
|
|
|
# If downloads are running, just hide the window.
|
|
|
self.hide()
|
|
|
event.ignore()
|
|
|
|
|
|
# Inner class for the search worker
|
|
|
class ParallelSearchWorker(QRunnable):
|
|
|
def __init__(self, soulseek_client, query):
|
|
|
super().__init__()
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.query = query
|
|
|
self.signals = self.create_signals()
|
|
|
|
|
|
def create_signals(self):
|
|
|
class Signals(QObject):
|
|
|
search_completed = pyqtSignal(list, str)
|
|
|
search_failed = pyqtSignal(str, str)
|
|
|
return Signals()
|
|
|
|
|
|
def run(self):
|
|
|
loop = None
|
|
|
try:
|
|
|
import asyncio
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
search_result = loop.run_until_complete(self.soulseek_client.search(self.query))
|
|
|
results_list = search_result[0] if isinstance(search_result, tuple) and search_result else []
|
|
|
|
|
|
# Check if signals object is still valid before emitting
|
|
|
try:
|
|
|
self.signals.search_completed.emit(results_list, self.query)
|
|
|
except RuntimeError:
|
|
|
# Qt objects deleted during shutdown, ignore
|
|
|
logger.debug(f"Search completed for '{self.query}' but UI already closed")
|
|
|
|
|
|
except Exception as e:
|
|
|
try:
|
|
|
self.signals.search_failed.emit(self.query, str(e))
|
|
|
except RuntimeError:
|
|
|
# Qt objects deleted during shutdown, ignore
|
|
|
logger.debug(f"Search failed for '{self.query}' but UI already closed: {e}")
|
|
|
finally:
|
|
|
if loop: loop.close()
|
|
|
|
|
|
def get_valid_candidates(self, results, spotify_track, query):
|
|
|
"""
|
|
|
Scores and filters search results, then performs a strict artist verification
|
|
|
by checking the file path. This prevents downloading tracks from the wrong artist.
|
|
|
"""
|
|
|
if not results:
|
|
|
return []
|
|
|
|
|
|
# Step 1: Get initial confident matches with version-aware scoring
|
|
|
# This gives us a sorted list of potential candidates, preferring originals.
|
|
|
initial_candidates = self.matching_engine.find_best_slskd_matches_enhanced(spotify_track, results)
|
|
|
|
|
|
if not initial_candidates:
|
|
|
print(f"⚠️ No initial candidates found for '{spotify_track.name}' from query '{query}'.")
|
|
|
return []
|
|
|
|
|
|
print(f"✅ Found {len(initial_candidates)} initial candidates for '{spotify_track.name}'. Now verifying artist...")
|
|
|
|
|
|
# Step 2: Perform strict artist verification on the initial candidates.
|
|
|
verified_candidates = []
|
|
|
spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
|
|
|
|
# **IMPROVEMENT**: More robust normalization for both artist name and file path.
|
|
|
# This removes all non-alphanumeric characters and converts to lowercase.
|
|
|
# e.g., "Virtual Mage" -> "virtualmage", "virtual-mage" -> "virtualmage"
|
|
|
normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower()
|
|
|
|
|
|
for candidate in initial_candidates:
|
|
|
# The 'filename' from Soulseek includes the full folder path.
|
|
|
slskd_full_path = candidate.filename
|
|
|
|
|
|
# Apply the same robust normalization to the Soulseek path.
|
|
|
normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', slskd_full_path).lower()
|
|
|
|
|
|
# **THE CRITICAL CHECK**: See if the cleaned artist's name is in the cleaned folder path.
|
|
|
if normalized_spotify_artist in normalized_slskd_path:
|
|
|
# Artist name was found in the path, this is a valid candidate.
|
|
|
print(f"✔️ Artist '{spotify_artist_name}' VERIFIED in path: '{slskd_full_path}'")
|
|
|
verified_candidates.append(candidate)
|
|
|
else:
|
|
|
# Artist name was NOT found. Discard this candidate.
|
|
|
print(f"❌ Artist '{spotify_artist_name}' NOT found in path: '{slskd_full_path}'. Discarding candidate.")
|
|
|
|
|
|
if verified_candidates:
|
|
|
# Apply quality preference filtering before returning
|
|
|
from config.settings import config_manager
|
|
|
quality_preference = config_manager.get_quality_preference()
|
|
|
|
|
|
# Filter candidates by quality preference with smart fallback
|
|
|
if hasattr(self.parent_page, 'soulseek_client'):
|
|
|
quality_filtered = self.parent_page.soulseek_client.filter_results_by_quality_preference(
|
|
|
verified_candidates, quality_preference
|
|
|
)
|
|
|
|
|
|
if quality_filtered:
|
|
|
verified_candidates = quality_filtered
|
|
|
print(f"🎯 Applied quality filtering ({quality_preference}): {len(verified_candidates)} candidates remain")
|
|
|
else:
|
|
|
print(f"⚠️ Quality filtering ({quality_preference}) removed all candidates, keeping originals")
|
|
|
|
|
|
best_confidence = verified_candidates[0].confidence
|
|
|
best_version = getattr(verified_candidates[0], 'version_type', 'unknown')
|
|
|
best_quality = getattr(verified_candidates[0], 'quality', 'unknown')
|
|
|
print(f"✅ Found {len(verified_candidates)} VERIFIED matches for '{spotify_track.name}'. Best: {best_confidence:.2f} ({best_version}, {best_quality.upper()})")
|
|
|
|
|
|
# Log version breakdown for debugging
|
|
|
for candidate in verified_candidates[:3]: # Show top 3
|
|
|
version = getattr(candidate, 'version_type', 'unknown')
|
|
|
penalty = getattr(candidate, 'version_penalty', 0.0)
|
|
|
quality = getattr(candidate, 'quality', 'unknown')
|
|
|
bitrate_info = f" {candidate.bitrate}kbps" if hasattr(candidate, 'bitrate') and candidate.bitrate else ""
|
|
|
print(f" 🎵 {candidate.confidence:.2f} - {version} ({quality.upper()}{bitrate_info}) (penalty: {penalty:.2f}) - {candidate.filename[:80]}...")
|
|
|
|
|
|
else:
|
|
|
print(f"⚠️ No verified matches found for '{spotify_track.name}' after checking file paths.")
|
|
|
|
|
|
return verified_candidates
|
|
|
def create_spotify_based_search_result_from_validation(self, slskd_result, spotify_metadata):
|
|
|
"""Create SpotifyBasedSearchResult from validation results"""
|
|
|
class SpotifyBasedSearchResult:
|
|
|
def __init__(self):
|
|
|
self.filename = getattr(slskd_result, 'filename', f"{spotify_metadata.name}.flac")
|
|
|
self.username = getattr(slskd_result, 'username', 'unknown')
|
|
|
self.size = getattr(slskd_result, 'size', 0)
|
|
|
self.quality = getattr(slskd_result, 'quality', 'flac')
|
|
|
self.artist = spotify_metadata.artists[0] if spotify_metadata.artists else "Unknown"
|
|
|
self.title = spotify_metadata.name
|
|
|
self.album = spotify_metadata.album
|
|
|
return SpotifyBasedSearchResult()
|
|
|
|
|
|
|
|
|
|