6747 lines
No EOL
297 KiB
Python
6747 lines
No EOL
297 KiB
Python
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
|
QFrame, QPushButton, QListWidget, QListWidgetItem,
|
|
QProgressBar, QTextEdit, QCheckBox, QComboBox,
|
|
QScrollArea, QSizePolicy, QMessageBox, QDialog,
|
|
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QLineEdit)
|
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject
|
|
from PyQt6.QtGui import QFont
|
|
from dataclasses import dataclass
|
|
from typing import List, Optional
|
|
from core.soulseek_client import TrackResult
|
|
|
|
@dataclass
|
|
class TrackAnalysisResult:
|
|
"""Result of analyzing a track for Plex existence"""
|
|
spotify_track: object # Spotify track object
|
|
exists_in_plex: bool
|
|
plex_match: Optional[object] = None # Plex track if found
|
|
confidence: float = 0.0
|
|
error_message: Optional[str] = None
|
|
|
|
class PlaylistTrackAnalysisWorkerSignals(QObject):
|
|
"""Signals for playlist track analysis worker"""
|
|
analysis_started = pyqtSignal(int) # total_tracks
|
|
track_analyzed = pyqtSignal(int, object) # track_index, TrackAnalysisResult
|
|
analysis_completed = pyqtSignal(list) # List[TrackAnalysisResult]
|
|
analysis_failed = pyqtSignal(str) # error_message
|
|
|
|
class PlaylistTrackAnalysisWorker(QRunnable):
|
|
"""Background worker to analyze playlist tracks against Plex library"""
|
|
|
|
def __init__(self, playlist_tracks, plex_client):
|
|
super().__init__()
|
|
self.playlist_tracks = playlist_tracks
|
|
self.plex_client = plex_client
|
|
self.signals = PlaylistTrackAnalysisWorkerSignals()
|
|
self._cancelled = False
|
|
|
|
def cancel(self):
|
|
"""Cancel the analysis operation"""
|
|
self._cancelled = True
|
|
|
|
def run(self):
|
|
"""Analyze each track in the playlist"""
|
|
try:
|
|
if self._cancelled:
|
|
return
|
|
|
|
self.signals.analysis_started.emit(len(self.playlist_tracks))
|
|
results = []
|
|
|
|
# Check if Plex is connected
|
|
plex_connected = False
|
|
try:
|
|
if self.plex_client:
|
|
plex_connected = self.plex_client.is_connected()
|
|
except Exception as e:
|
|
print(f"Plex connection check failed: {e}")
|
|
plex_connected = False
|
|
|
|
for i, track in enumerate(self.playlist_tracks):
|
|
if self._cancelled:
|
|
return
|
|
|
|
result = TrackAnalysisResult(
|
|
spotify_track=track,
|
|
exists_in_plex=False
|
|
)
|
|
|
|
if plex_connected:
|
|
# Check if track exists in Plex
|
|
try:
|
|
plex_match, confidence = self._check_track_in_plex(track)
|
|
if plex_match and confidence >= 0.8: # High confidence threshold
|
|
result.exists_in_plex = True
|
|
result.plex_match = plex_match
|
|
result.confidence = confidence
|
|
except Exception as e:
|
|
result.error_message = f"Plex check failed: {str(e)}"
|
|
|
|
results.append(result)
|
|
self.signals.track_analyzed.emit(i + 1, result)
|
|
|
|
if not self._cancelled:
|
|
self.signals.analysis_completed.emit(results)
|
|
|
|
except Exception as e:
|
|
if not self._cancelled:
|
|
self.signals.analysis_failed.emit(str(e))
|
|
|
|
def _check_track_in_plex(self, spotify_track):
|
|
"""Check if a Spotify track exists in Plex with confidence scoring"""
|
|
try:
|
|
# Search Plex for similar tracks
|
|
# Use first artist for search
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
search_query = f"{artist_name} {spotify_track.name}".strip()
|
|
|
|
# Get potential matches from Plex
|
|
plex_tracks = self.plex_client.search_tracks(search_query, limit=10)
|
|
|
|
if not plex_tracks:
|
|
return None, 0.0
|
|
|
|
# Find best match using confidence scoring
|
|
best_match = None
|
|
best_confidence = 0.0
|
|
|
|
for plex_track in plex_tracks:
|
|
confidence = self._calculate_track_confidence(spotify_track, plex_track)
|
|
if confidence > best_confidence:
|
|
best_confidence = confidence
|
|
best_match = plex_track
|
|
|
|
return best_match, best_confidence
|
|
|
|
except Exception as e:
|
|
print(f"Error checking track in Plex: {e}")
|
|
return None, 0.0
|
|
|
|
def _calculate_track_confidence(self, spotify_track, plex_track):
|
|
"""Calculate confidence score between Spotify and Plex tracks"""
|
|
try:
|
|
# Basic string similarity for now (can be enhanced with existing matching engine)
|
|
import re
|
|
|
|
def normalize_string(s):
|
|
return re.sub(r'[^a-zA-Z0-9\s]', '', s.lower()).strip()
|
|
|
|
# Normalize track titles
|
|
spotify_title = normalize_string(spotify_track.name)
|
|
plex_title = normalize_string(plex_track.title)
|
|
|
|
# Normalize artist names
|
|
spotify_artist = normalize_string(spotify_track.artists[0]) if spotify_track.artists else ""
|
|
plex_artist = normalize_string(plex_track.artist)
|
|
|
|
# Simple similarity scoring
|
|
title_similarity = 1.0 if spotify_title == plex_title else 0.0
|
|
artist_similarity = 1.0 if spotify_artist == plex_artist else 0.0
|
|
|
|
# Weight title more heavily
|
|
confidence = (title_similarity * 0.7) + (artist_similarity * 0.3)
|
|
|
|
# Duration check (allow 10% variance)
|
|
if hasattr(spotify_track, 'duration_ms') and hasattr(plex_track, 'duration'):
|
|
spotify_duration = spotify_track.duration_ms / 1000
|
|
plex_duration = plex_track.duration / 1000 if plex_track.duration else 0
|
|
|
|
if plex_duration > 0:
|
|
duration_diff = abs(spotify_duration - plex_duration) / max(spotify_duration, plex_duration)
|
|
if duration_diff <= 0.1: # Within 10%
|
|
confidence += 0.1 # Bonus for duration match
|
|
|
|
return min(confidence, 1.0) # Cap at 1.0
|
|
|
|
except Exception as e:
|
|
print(f"Error calculating track confidence: {e}")
|
|
return 0.0
|
|
|
|
class TrackDownloadWorkerSignals(QObject):
|
|
"""Signals for track download worker"""
|
|
download_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):
|
|
super().__init__()
|
|
self.spotify_track = spotify_track
|
|
self.soulseek_client = soulseek_client
|
|
self.download_index = download_index
|
|
self.track_index = track_index
|
|
self.signals = TrackDownloadWorkerSignals()
|
|
self._cancelled = False
|
|
|
|
def cancel(self):
|
|
"""Cancel the download operation"""
|
|
self._cancelled = True
|
|
|
|
def run(self):
|
|
"""Download the track via Soulseek"""
|
|
try:
|
|
if self._cancelled or not self.soulseek_client:
|
|
return
|
|
|
|
# Create search queries - try track name first, then artist + track
|
|
track_name = self.spotify_track.name
|
|
artist_name = self.spotify_track.artists[0] if self.spotify_track.artists else ""
|
|
|
|
search_queries = []
|
|
search_queries.append(track_name) # Try track name only first
|
|
if artist_name:
|
|
search_queries.append(f"{artist_name} {track_name}") # Then artist + track
|
|
|
|
download_id = None
|
|
|
|
# Try each search query until we find a download
|
|
for query in search_queries:
|
|
if self._cancelled:
|
|
return
|
|
|
|
print(f"🔍 Searching Soulseek: {query}")
|
|
|
|
# Use the async method (need to run in sync context)
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
try:
|
|
download_id = loop.run_until_complete(
|
|
self.soulseek_client.search_and_download_best(query)
|
|
)
|
|
if download_id:
|
|
break # Success - stop trying other queries
|
|
finally:
|
|
loop.close()
|
|
|
|
if download_id:
|
|
self.signals.download_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 and returns a list of updates for the main thread.
|
|
"""
|
|
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
|
|
|
|
def run(self):
|
|
"""The main logic of the background worker."""
|
|
try:
|
|
import asyncio
|
|
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:
|
|
self.signals.completed.emit([])
|
|
return
|
|
|
|
all_transfers = [
|
|
file for user_data in transfers_data if 'directories' in user_data
|
|
for directory in user_data['directories'] if 'files' in directory
|
|
for file in directory['files']
|
|
]
|
|
|
|
transfers_by_id = {t['id']: t for t in all_transfers}
|
|
|
|
for item_data in self.download_items_data:
|
|
matching_transfer = transfers_by_id.get(item_data['download_id'])
|
|
|
|
if not matching_transfer:
|
|
import os
|
|
dl_filename = os.path.basename(item_data['file_path']).lower()
|
|
for t in all_transfers:
|
|
if os.path.basename(t.get('filename', '')).lower() == dl_filename:
|
|
matching_transfer = t
|
|
break
|
|
|
|
if matching_transfer:
|
|
state = matching_transfer.get('state', 'Unknown')
|
|
progress = matching_transfer.get('percentComplete', 0)
|
|
new_status = 'queued'
|
|
|
|
# Correct order of checks: Terminal states first.
|
|
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'], # This will be the download_index
|
|
'status': new_status,
|
|
'progress': int(progress),
|
|
'transfer_id': matching_transfer.get('id'),
|
|
'username': matching_transfer.get('username')
|
|
}
|
|
results.append(payload)
|
|
else:
|
|
# Handle downloads that disappear from the API
|
|
item_data['api_missing_count'] = item_data.get('api_missing_count', 0) + 1
|
|
if item_data['api_missing_count'] >= 3:
|
|
payload = {
|
|
'widget_id': item_data['widget_id'],
|
|
'status': 'failed',
|
|
'transfer_id': item_data['download_id'],
|
|
}
|
|
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 PlaylistDetailsModal(QDialog):
|
|
def __init__(self, playlist, parent=None):
|
|
super().__init__(parent)
|
|
self.playlist = playlist
|
|
self.parent_page = parent
|
|
self.spotify_client = parent.spotify_client if parent else None
|
|
|
|
# Thread management
|
|
self.active_workers = []
|
|
self.fallback_pools = []
|
|
self.is_closing = False
|
|
|
|
self.setup_ui()
|
|
|
|
# Load tracks asynchronously if not already cached
|
|
if not self.playlist.tracks and self.spotify_client:
|
|
# Check cache first
|
|
if hasattr(parent, 'track_cache') and playlist.id in parent.track_cache:
|
|
self.playlist.tracks = parent.track_cache[playlist.id]
|
|
self.refresh_track_table()
|
|
else:
|
|
self.load_tracks_async()
|
|
|
|
def closeEvent(self, event):
|
|
"""Clean up threads and resources when modal is closed"""
|
|
self.is_closing = True
|
|
self.cleanup_workers()
|
|
super().closeEvent(event)
|
|
|
|
def cleanup_workers(self):
|
|
"""Clean up all active workers and thread pools"""
|
|
# Cancel active workers first
|
|
for worker in self.active_workers:
|
|
try:
|
|
if hasattr(worker, 'cancel'):
|
|
worker.cancel()
|
|
except (RuntimeError, AttributeError):
|
|
pass
|
|
|
|
# Disconnect signals from active workers to prevent race conditions
|
|
for worker in self.active_workers:
|
|
try:
|
|
if hasattr(worker, 'signals'):
|
|
# Disconnect track loading worker signals
|
|
try:
|
|
worker.signals.tracks_loaded.disconnect(self.on_tracks_loaded)
|
|
except (RuntimeError, TypeError):
|
|
pass
|
|
try:
|
|
worker.signals.loading_failed.disconnect(self.on_tracks_loading_failed)
|
|
except (RuntimeError, TypeError):
|
|
pass
|
|
|
|
# Disconnect playlist analysis worker signals
|
|
try:
|
|
worker.signals.analysis_started.disconnect(self.on_analysis_started)
|
|
except (RuntimeError, TypeError):
|
|
pass
|
|
try:
|
|
worker.signals.track_analyzed.disconnect(self.on_track_analyzed)
|
|
except (RuntimeError, TypeError):
|
|
pass
|
|
try:
|
|
worker.signals.analysis_completed.disconnect(self.on_analysis_completed)
|
|
except (RuntimeError, TypeError):
|
|
pass
|
|
try:
|
|
worker.signals.analysis_failed.disconnect(self.on_analysis_failed)
|
|
except (RuntimeError, TypeError):
|
|
pass
|
|
except (RuntimeError, AttributeError):
|
|
# Signal may already be disconnected or worker deleted
|
|
pass
|
|
|
|
# Clean up fallback thread pools with timeout
|
|
for pool in self.fallback_pools:
|
|
try:
|
|
pool.clear() # Cancel pending workers
|
|
if not pool.waitForDone(2000): # Wait 2 seconds max
|
|
# Force termination if workers don't finish gracefully
|
|
pool.clear()
|
|
except (RuntimeError, AttributeError):
|
|
pass
|
|
|
|
# Clear tracking lists
|
|
self.active_workers.clear()
|
|
self.fallback_pools.clear()
|
|
|
|
def setup_ui(self):
|
|
self.setWindowTitle(f"Playlist Details - {self.playlist.name}")
|
|
self.setFixedSize(900, 700)
|
|
self.setStyleSheet("""
|
|
QDialog {
|
|
background: #191414;
|
|
color: #ffffff;
|
|
}
|
|
""")
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
main_layout.setContentsMargins(30, 30, 30, 30)
|
|
main_layout.setSpacing(20)
|
|
|
|
# Header section
|
|
header = self.create_header()
|
|
main_layout.addWidget(header)
|
|
|
|
# Track list section
|
|
track_list = self.create_track_list()
|
|
main_layout.addWidget(track_list)
|
|
|
|
# Button section
|
|
button_widget = QWidget()
|
|
button_layout = self.create_buttons()
|
|
button_widget.setLayout(button_layout)
|
|
main_layout.addWidget(button_widget)
|
|
|
|
def create_header(self):
|
|
header = QFrame()
|
|
header.setStyleSheet("""
|
|
QFrame {
|
|
background: #282828;
|
|
border-radius: 12px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(header)
|
|
layout.setContentsMargins(25, 20, 25, 20)
|
|
layout.setSpacing(10)
|
|
|
|
# Playlist name
|
|
name_label = QLabel(self.playlist.name)
|
|
name_label.setFont(QFont("Arial", 20, QFont.Weight.Bold))
|
|
name_label.setStyleSheet("color: #ffffff; border: none; background: transparent;")
|
|
|
|
# Playlist info
|
|
info_layout = QHBoxLayout()
|
|
info_layout.setSpacing(20)
|
|
|
|
# Track count
|
|
track_count = QLabel(f"{self.playlist.total_tracks} tracks")
|
|
track_count.setFont(QFont("Arial", 12))
|
|
track_count.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
|
|
|
|
# Owner
|
|
owner = QLabel(f"by {self.playlist.owner}")
|
|
owner.setFont(QFont("Arial", 12))
|
|
owner.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
|
|
|
|
# Public/Private status
|
|
visibility = "Public" if self.playlist.public else "Private"
|
|
if self.playlist.collaborative:
|
|
visibility = "Collaborative"
|
|
status = QLabel(visibility)
|
|
status.setFont(QFont("Arial", 12))
|
|
status.setStyleSheet("color: #1db954; border: none; background: transparent;")
|
|
|
|
info_layout.addWidget(track_count)
|
|
info_layout.addWidget(owner)
|
|
info_layout.addWidget(status)
|
|
info_layout.addStretch()
|
|
|
|
# Description (if available)
|
|
if self.playlist.description:
|
|
desc_label = QLabel(self.playlist.description)
|
|
desc_label.setFont(QFont("Arial", 11))
|
|
desc_label.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
|
|
desc_label.setWordWrap(True)
|
|
desc_label.setMaximumHeight(60)
|
|
layout.addWidget(desc_label)
|
|
|
|
layout.addWidget(name_label)
|
|
layout.addLayout(info_layout)
|
|
|
|
return header
|
|
|
|
def create_track_list(self):
|
|
container = QFrame()
|
|
container.setStyleSheet("""
|
|
QFrame {
|
|
background: #282828;
|
|
border-radius: 12px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(container)
|
|
layout.setContentsMargins(25, 20, 25, 20)
|
|
layout.setSpacing(15)
|
|
|
|
# Section title
|
|
title = QLabel("Tracks")
|
|
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
title.setStyleSheet("color: #ffffff; border: none; background: transparent;")
|
|
|
|
# Track table
|
|
self.track_table = QTableWidget()
|
|
self.track_table.setColumnCount(4)
|
|
self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Album", "Duration"])
|
|
|
|
# Set initial row count (may be 0 if tracks not loaded yet)
|
|
track_count = len(self.playlist.tracks) if self.playlist.tracks else 1
|
|
self.track_table.setRowCount(track_count)
|
|
|
|
# Style the table
|
|
self.track_table.setStyleSheet("""
|
|
QTableWidget {
|
|
background: #181818;
|
|
border: 1px solid #404040;
|
|
border-radius: 8px;
|
|
gridline-color: #404040;
|
|
color: #ffffff;
|
|
font-size: 11px;
|
|
}
|
|
QTableWidget::item {
|
|
padding: 8px;
|
|
border-bottom: 1px solid #333333;
|
|
}
|
|
QTableWidget::item:selected {
|
|
background: #1db954;
|
|
color: #000000;
|
|
}
|
|
QHeaderView::section {
|
|
background: #404040;
|
|
color: #ffffff;
|
|
padding: 10px;
|
|
border: none;
|
|
font-weight: bold;
|
|
font-size: 11px;
|
|
}
|
|
""")
|
|
|
|
# Populate table
|
|
if self.playlist.tracks:
|
|
for row, track in enumerate(self.playlist.tracks):
|
|
# Track name
|
|
track_item = QTableWidgetItem(track.name)
|
|
track_item.setFlags(track_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(row, 0, track_item)
|
|
|
|
# Artist(s)
|
|
artists = ", ".join(track.artists)
|
|
artist_item = QTableWidgetItem(artists)
|
|
artist_item.setFlags(artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(row, 1, artist_item)
|
|
|
|
# Album
|
|
album_item = QTableWidgetItem(track.album)
|
|
album_item.setFlags(album_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(row, 2, album_item)
|
|
|
|
# Duration
|
|
duration = self.format_duration(track.duration_ms)
|
|
duration_item = QTableWidgetItem(duration)
|
|
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(row, 3, duration_item)
|
|
else:
|
|
# Show placeholder while tracks are being loaded
|
|
placeholder_item = QTableWidgetItem("Tracks will load momentarily...")
|
|
placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(0, 0, placeholder_item)
|
|
self.track_table.setSpan(0, 0, 1, 4)
|
|
|
|
# Set optimal column widths with proportional sizing
|
|
header = self.track_table.horizontalHeader()
|
|
header.setStretchLastSection(False)
|
|
|
|
# Calculate available width (modal is 900px, account for margins/scrollbar)
|
|
available_width = 850
|
|
|
|
# Set proportional widths: Track(40%), Artist(25%), Album(25%), Duration(10%)
|
|
track_width = int(available_width * 0.40) # ~340px
|
|
artist_width = int(available_width * 0.25) # ~212px
|
|
album_width = int(available_width * 0.25) # ~212px
|
|
duration_width = 80 # Fixed 80px
|
|
|
|
# Apply column widths with interactive resize capability
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) # Track name
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # Artist
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # Album
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Duration
|
|
|
|
self.track_table.setColumnWidth(0, track_width)
|
|
self.track_table.setColumnWidth(1, artist_width)
|
|
self.track_table.setColumnWidth(2, album_width)
|
|
self.track_table.setColumnWidth(3, duration_width)
|
|
|
|
# Set minimum widths to prevent columns from becoming too narrow
|
|
header.setMinimumSectionSize(100) # Minimum 100px for any column
|
|
|
|
# Hide row numbers
|
|
self.track_table.verticalHeader().setVisible(False)
|
|
|
|
layout.addWidget(title)
|
|
layout.addWidget(self.track_table)
|
|
|
|
return container
|
|
|
|
def create_buttons(self):
|
|
button_layout = QHBoxLayout()
|
|
button_layout.setSpacing(15)
|
|
|
|
# Close button
|
|
close_btn = QPushButton("Close")
|
|
close_btn.setFixedSize(100, 40)
|
|
close_btn.clicked.connect(self.close)
|
|
close_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #404040;
|
|
border: none;
|
|
border-radius: 20px;
|
|
color: #ffffff;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #505050;
|
|
}
|
|
""")
|
|
|
|
# Sync button
|
|
sync_btn = QPushButton("Sync This Playlist")
|
|
sync_btn.setFixedSize(150, 40)
|
|
sync_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: none;
|
|
border-radius: 20px;
|
|
color: #000000;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
}
|
|
""")
|
|
|
|
# Download missing tracks button
|
|
download_btn = QPushButton("Download Missing Tracks")
|
|
download_btn.setFixedSize(180, 40)
|
|
download_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: transparent;
|
|
border: 1px solid #1db954;
|
|
border-radius: 20px;
|
|
color: #1db954;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: rgba(29, 185, 84, 0.1);
|
|
}
|
|
""")
|
|
|
|
# Connect download missing tracks button
|
|
download_btn.clicked.connect(self.on_download_missing_tracks_clicked)
|
|
|
|
# Connect sync button
|
|
sync_btn.clicked.connect(self.on_sync_playlist_clicked)
|
|
|
|
button_layout.addStretch()
|
|
button_layout.addWidget(close_btn)
|
|
button_layout.addWidget(download_btn)
|
|
button_layout.addWidget(sync_btn)
|
|
|
|
return button_layout
|
|
|
|
def format_duration(self, duration_ms):
|
|
"""Convert milliseconds to MM:SS format"""
|
|
seconds = duration_ms // 1000
|
|
minutes = seconds // 60
|
|
seconds = seconds % 60
|
|
return f"{minutes}:{seconds:02d}"
|
|
|
|
def on_download_missing_tracks_clicked(self):
|
|
"""Handle Download Missing Tracks button click"""
|
|
print("🔄 Download Missing Tracks button clicked!")
|
|
|
|
if not self.playlist:
|
|
print("❌ No playlist selected")
|
|
QMessageBox.warning(self, "Error", "No playlist selected")
|
|
return
|
|
|
|
if not self.playlist.tracks:
|
|
print("❌ Playlist tracks not loaded")
|
|
QMessageBox.warning(self, "Error", "Playlist tracks not loaded")
|
|
return
|
|
|
|
print(f"✅ Playlist: {self.playlist.name} with {len(self.playlist.tracks)} tracks")
|
|
|
|
# Get access to parent's Plex and Soulseek clients through parent reference
|
|
if not hasattr(self.parent_page, 'plex_client'):
|
|
print("❌ Plex client not available")
|
|
QMessageBox.warning(self, "Service Unavailable",
|
|
"Plex client not available. Please check your configuration.")
|
|
return
|
|
|
|
if not hasattr(self.parent_page, 'soulseek_client') or not self.parent_page.soulseek_client:
|
|
print("❌ Soulseek client not available")
|
|
QMessageBox.warning(self, "Service Unavailable",
|
|
"Soulseek client not available. Please check your configuration.")
|
|
return
|
|
|
|
print("✅ Plex and Soulseek clients available")
|
|
|
|
# Create and show the enhanced download missing tracks modal
|
|
try:
|
|
print("🚀 Creating modal...")
|
|
modal = DownloadMissingTracksModal(self.playlist, self.parent_page, self, self.parent_page.downloads_page)
|
|
print("✅ Modal created successfully")
|
|
|
|
# Store modal reference to prevent garbage collection
|
|
self.download_modal = modal
|
|
|
|
# Find and store modal reference in playlist item for reopening
|
|
playlist_item = self.find_playlist_item_from_sync_modal()
|
|
if playlist_item:
|
|
playlist_item.set_download_modal(modal)
|
|
|
|
print("🖥️ Closing current sync modal...")
|
|
self.accept() # Close the current sync modal
|
|
|
|
print("🖥️ Showing download modal...")
|
|
result = modal.exec()
|
|
print(f"✅ Modal closed with result: {result}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Exception creating modal: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
QMessageBox.critical(self, "Modal Error", f"Failed to open download modal: {str(e)}")
|
|
|
|
def find_playlist_item_from_sync_modal(self):
|
|
"""Find the PlaylistItem widget for this playlist from sync modal"""
|
|
if not hasattr(self.parent_page, 'current_playlists'):
|
|
return None
|
|
|
|
# Look through the parent page's playlist items
|
|
for i in range(self.parent_page.playlist_layout.count()):
|
|
item = self.parent_page.playlist_layout.itemAt(i)
|
|
if item and item.widget() and isinstance(item.widget(), PlaylistItem):
|
|
playlist_item = item.widget()
|
|
if playlist_item.playlist and playlist_item.playlist.id == self.playlist.id:
|
|
return playlist_item
|
|
return None
|
|
|
|
def on_sync_playlist_clicked(self):
|
|
"""Handle Sync This Playlist button click"""
|
|
if not self.playlist:
|
|
QMessageBox.warning(self, "Error", "No playlist selected")
|
|
return
|
|
|
|
if not self.playlist.tracks:
|
|
QMessageBox.warning(self, "Error", "Playlist tracks not loaded")
|
|
return
|
|
|
|
# Check if sync service is available
|
|
if not hasattr(self.parent_page, 'sync_service'):
|
|
# Create sync service if not available
|
|
from services.sync_service import PlaylistSyncService
|
|
self.parent_page.sync_service = PlaylistSyncService(
|
|
self.parent_page.spotify_client,
|
|
self.parent_page.plex_client,
|
|
self.parent_page.soulseek_client
|
|
)
|
|
|
|
# Set up progress callback to update console
|
|
self.parent_page.sync_service.set_progress_callback(self.on_sync_progress)
|
|
|
|
# Add initial console log
|
|
self.parent_page.log_area.append(f"🔄 Starting sync for playlist: {self.playlist.name}")
|
|
|
|
# Start sync in background thread
|
|
self.start_sync_thread()
|
|
|
|
# Close modal to return to main view
|
|
self.accept()
|
|
|
|
def on_sync_progress(self, progress):
|
|
"""Handle sync progress updates and forward to console"""
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
progress_msg = f"⏳ {progress.current_step}"
|
|
if progress.current_track:
|
|
progress_msg += f" - {progress.current_track}"
|
|
progress_msg += f" ({progress.progress:.1f}%)"
|
|
self.parent_page.log_area.append(progress_msg)
|
|
|
|
def start_sync_thread(self):
|
|
"""Start playlist sync in a background thread"""
|
|
import asyncio
|
|
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
|
|
|
|
class SyncWorkerSignals(QObject):
|
|
finished = pyqtSignal(object) # SyncResult
|
|
error = pyqtSignal(str)
|
|
|
|
class SyncWorker(QRunnable):
|
|
def __init__(self, sync_service, playlist_name):
|
|
super().__init__()
|
|
self.sync_service = sync_service
|
|
self.playlist_name = playlist_name
|
|
self.signals = SyncWorkerSignals()
|
|
|
|
def run(self):
|
|
try:
|
|
# Create new event loop for this thread
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
# Run sync
|
|
result = loop.run_until_complete(
|
|
self.sync_service.sync_playlist(self.playlist_name, download_missing=False)
|
|
)
|
|
|
|
loop.close()
|
|
self.signals.finished.emit(result)
|
|
|
|
except Exception as e:
|
|
self.signals.error.emit(str(e))
|
|
|
|
# Create and start worker
|
|
worker = SyncWorker(self.parent_page.sync_service, self.playlist.name)
|
|
worker.signals.finished.connect(self.on_sync_finished)
|
|
worker.signals.error.connect(self.on_sync_error)
|
|
|
|
# Submit to thread pool
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
# Create fallback thread pool
|
|
thread_pool = QThreadPool()
|
|
thread_pool.start(worker)
|
|
|
|
def on_sync_finished(self, result):
|
|
"""Handle sync completion"""
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
success_rate = result.success_rate
|
|
msg = f"✅ Sync complete: {result.synced_tracks}/{result.total_tracks} tracks synced ({success_rate:.1f}%)"
|
|
if result.failed_tracks > 0:
|
|
msg += f", {result.failed_tracks} failed"
|
|
self.parent_page.log_area.append(msg)
|
|
|
|
# Add detailed results
|
|
if result.errors:
|
|
for error in result.errors:
|
|
self.parent_page.log_area.append(f"❌ Error: {error}")
|
|
|
|
def on_sync_error(self, error):
|
|
"""Handle sync error"""
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f"❌ Sync failed: {error}")
|
|
QMessageBox.critical(self, "Sync Error", f"Sync failed: {error}")
|
|
|
|
def start_playlist_missing_tracks_download(self):
|
|
"""Start the process of downloading missing tracks from playlist"""
|
|
track_count = len(self.playlist.tracks)
|
|
|
|
# Start analysis worker
|
|
self.start_track_analysis()
|
|
|
|
# Show analysis started message
|
|
QMessageBox.information(self, "Analysis Started",
|
|
f"Starting analysis of {track_count} tracks.\nChecking Plex library for existing tracks...")
|
|
|
|
def start_track_analysis(self):
|
|
"""Start background track analysis against Plex library"""
|
|
# Create analysis worker
|
|
plex_client = getattr(self.parent_page, 'plex_client', None)
|
|
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, plex_client)
|
|
|
|
# Connect signals
|
|
worker.signals.analysis_started.connect(self.on_analysis_started)
|
|
worker.signals.track_analyzed.connect(self.on_track_analyzed)
|
|
worker.signals.analysis_completed.connect(self.on_analysis_completed)
|
|
worker.signals.analysis_failed.connect(self.on_analysis_failed)
|
|
|
|
# Track worker for cleanup
|
|
self.active_workers.append(worker)
|
|
|
|
# Submit to thread pool
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
# Create and track fallback thread pool
|
|
thread_pool = QThreadPool()
|
|
self.fallback_pools.append(thread_pool)
|
|
thread_pool.start(worker)
|
|
|
|
def on_analysis_started(self, total_tracks):
|
|
"""Handle analysis started signal"""
|
|
print(f"Started analyzing {total_tracks} tracks against Plex library")
|
|
|
|
def on_track_analyzed(self, track_index, result):
|
|
"""Handle individual track analysis completion"""
|
|
track = result.spotify_track
|
|
if result.exists_in_plex:
|
|
print(f"Track {track_index}: '{track.name}' by {track.artists[0]} EXISTS in Plex (confidence: {result.confidence:.2f})")
|
|
else:
|
|
print(f"Track {track_index}: '{track.name}' by {track.artists[0]} MISSING from Plex - will download")
|
|
|
|
def on_analysis_completed(self, results):
|
|
"""Handle analysis completion and start downloads for missing tracks"""
|
|
missing_tracks = [r for r in results if not r.exists_in_plex]
|
|
existing_tracks = [r for r in results if r.exists_in_plex]
|
|
|
|
print(f"Analysis complete: {len(missing_tracks)} missing, {len(existing_tracks)} existing")
|
|
|
|
if not missing_tracks:
|
|
QMessageBox.information(self, "Analysis Complete",
|
|
"All tracks already exist in Plex library!\nNo downloads needed.")
|
|
return
|
|
|
|
# Show results to user
|
|
message = f"Analysis complete!\n\n"
|
|
message += f"Tracks already in Plex: {len(existing_tracks)}\n"
|
|
message += f"Tracks to download: {len(missing_tracks)}\n\n"
|
|
message += "Ready to start downloading missing tracks?"
|
|
|
|
reply = QMessageBox.question(self, "Start Downloads?", message,
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
self.start_missing_track_downloads(missing_tracks)
|
|
|
|
def on_analysis_failed(self, error_message):
|
|
"""Handle analysis failure"""
|
|
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
|
|
|
|
def start_missing_track_downloads(self, missing_tracks):
|
|
"""Start downloading the missing tracks"""
|
|
# TODO: Implement Soulseek search and download queueing
|
|
# For now, just show what would be downloaded
|
|
track_list = []
|
|
for result in missing_tracks:
|
|
track = result.spotify_track
|
|
artist = track.artists[0] if track.artists else "Unknown Artist"
|
|
track_list.append(f"• {track.name} by {artist}")
|
|
|
|
message = f"Would download {len(missing_tracks)} tracks:\n\n"
|
|
message += "\n".join(track_list[:10]) # Show first 10
|
|
if len(track_list) > 10:
|
|
message += f"\n... and {len(track_list) - 10} more"
|
|
|
|
QMessageBox.information(self, "Downloads Queued", message)
|
|
|
|
def load_tracks_async(self):
|
|
"""Load tracks asynchronously using worker thread"""
|
|
if not self.spotify_client:
|
|
return
|
|
|
|
# Show loading state in track table
|
|
if hasattr(self, 'track_table'):
|
|
self.track_table.setRowCount(1)
|
|
loading_item = QTableWidgetItem("Loading tracks...")
|
|
loading_item.setFlags(loading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(0, 0, loading_item)
|
|
self.track_table.setSpan(0, 0, 1, 4)
|
|
|
|
# Create and submit worker to thread pool
|
|
worker = TrackLoadingWorker(self.spotify_client, self.playlist.id, self.playlist.name)
|
|
worker.signals.tracks_loaded.connect(self.on_tracks_loaded)
|
|
worker.signals.loading_failed.connect(self.on_tracks_loading_failed)
|
|
|
|
# Track active worker for cleanup
|
|
self.active_workers.append(worker)
|
|
|
|
# Submit to parent's thread pool if available, otherwise create one
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
# Create and track fallback thread pool
|
|
thread_pool = QThreadPool()
|
|
self.fallback_pools.append(thread_pool)
|
|
thread_pool.start(worker)
|
|
|
|
def on_tracks_loaded(self, playlist_id, tracks):
|
|
"""Handle successful track loading"""
|
|
# Validate modal state before processing
|
|
if (playlist_id == self.playlist.id and
|
|
not self.is_closing and
|
|
not self.isHidden() and
|
|
hasattr(self, 'track_table')):
|
|
|
|
self.playlist.tracks = tracks
|
|
|
|
# Cache tracks in parent for future use
|
|
if hasattr(self.parent_page, 'track_cache'):
|
|
self.parent_page.track_cache[playlist_id] = tracks
|
|
|
|
# Refresh the track table
|
|
self.refresh_track_table()
|
|
|
|
def on_tracks_loading_failed(self, playlist_id, error_message):
|
|
"""Handle track loading failure"""
|
|
# Validate modal state before processing
|
|
if (playlist_id == self.playlist.id and
|
|
not self.is_closing and
|
|
not self.isHidden() and
|
|
hasattr(self, 'track_table')):
|
|
self.track_table.setRowCount(1)
|
|
error_item = QTableWidgetItem(f"Error loading tracks: {error_message}")
|
|
error_item.setFlags(error_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(0, 0, error_item)
|
|
self.track_table.setSpan(0, 0, 1, 4)
|
|
|
|
def refresh_track_table(self):
|
|
"""Refresh the track table with loaded tracks"""
|
|
if not hasattr(self, 'track_table'):
|
|
return
|
|
|
|
self.track_table.setRowCount(len(self.playlist.tracks))
|
|
self.track_table.clearSpans() # Remove any spans from loading state
|
|
|
|
# Populate table
|
|
for row, track in enumerate(self.playlist.tracks):
|
|
# Track name
|
|
track_item = QTableWidgetItem(track.name)
|
|
track_item.setFlags(track_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(row, 0, track_item)
|
|
|
|
# Artist(s)
|
|
artists = ", ".join(track.artists)
|
|
artist_item = QTableWidgetItem(artists)
|
|
artist_item.setFlags(artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(row, 1, artist_item)
|
|
|
|
# Album
|
|
album_item = QTableWidgetItem(track.album)
|
|
album_item.setFlags(album_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(row, 2, album_item)
|
|
|
|
# Duration
|
|
duration = self.format_duration(track.duration_ms)
|
|
duration_item = QTableWidgetItem(duration)
|
|
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(row, 3, duration_item)
|
|
|
|
class PlaylistItem(QFrame):
|
|
view_details_clicked = pyqtSignal(object) # Signal to emit playlist object
|
|
|
|
def __init__(self, name: str, track_count: int, sync_status: str, playlist=None, parent=None):
|
|
super().__init__(parent)
|
|
self.name = name
|
|
self.track_count = track_count
|
|
self.sync_status = sync_status
|
|
self.playlist = playlist # Store playlist object reference
|
|
self.is_selected = False
|
|
self.setup_ui()
|
|
|
|
def setup_ui(self):
|
|
self.setFixedHeight(80)
|
|
self.setStyleSheet("""
|
|
PlaylistItem {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
PlaylistItem:hover {
|
|
background: #333333;
|
|
border: 1px solid #1db954;
|
|
}
|
|
""")
|
|
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(20, 15, 20, 15)
|
|
layout.setSpacing(15)
|
|
|
|
# Checkbox
|
|
self.checkbox = QCheckBox()
|
|
self.checkbox.setStyleSheet("""
|
|
QCheckBox::indicator {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 9px;
|
|
border: 2px solid #b3b3b3;
|
|
background: transparent;
|
|
}
|
|
QCheckBox::indicator:checked {
|
|
background: #1db954;
|
|
border: 2px solid #1db954;
|
|
}
|
|
QCheckBox::indicator:checked:hover {
|
|
background: #1ed760;
|
|
}
|
|
""")
|
|
|
|
# Content layout
|
|
content_layout = QVBoxLayout()
|
|
content_layout.setSpacing(5)
|
|
|
|
# Playlist name
|
|
name_label = QLabel(self.name)
|
|
name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
|
name_label.setStyleSheet("color: #ffffff;")
|
|
|
|
# Track count and status
|
|
info_layout = QHBoxLayout()
|
|
info_layout.setSpacing(20)
|
|
|
|
track_label = QLabel(f"{self.track_count} tracks")
|
|
track_label.setFont(QFont("Arial", 10))
|
|
track_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
status_label = QLabel(self.sync_status)
|
|
status_label.setFont(QFont("Arial", 10))
|
|
if self.sync_status == "Synced":
|
|
status_label.setStyleSheet("color: #1db954;")
|
|
elif self.sync_status == "Needs Sync":
|
|
status_label.setStyleSheet("color: #ffa500;")
|
|
else:
|
|
status_label.setStyleSheet("color: #e22134;")
|
|
|
|
info_layout.addWidget(track_label)
|
|
info_layout.addWidget(status_label)
|
|
info_layout.addStretch()
|
|
|
|
content_layout.addWidget(name_label)
|
|
content_layout.addLayout(info_layout)
|
|
|
|
# Action button or status indicator
|
|
self.action_btn = QPushButton("Sync / Download")
|
|
self.action_btn.setFixedSize(120, 30) # Slightly wider for longer text
|
|
self.action_btn.clicked.connect(self.on_view_details_clicked)
|
|
self.action_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: transparent;
|
|
border: 1px solid #1db954;
|
|
border-radius: 15px;
|
|
color: #1db954;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1db954;
|
|
color: #000000;
|
|
}
|
|
""")
|
|
|
|
# Status label (hidden by default)
|
|
self.status_label = QPushButton()
|
|
self.status_label.setFixedSize(120, 30)
|
|
self.status_label.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: 1px solid #169441;
|
|
border-radius: 15px;
|
|
color: #000000;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
padding: 5px;
|
|
text-align: center;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
cursor: pointer;
|
|
}
|
|
""")
|
|
self.status_label.clicked.connect(self.on_status_clicked)
|
|
self.status_label.hide()
|
|
|
|
# Store reference to the download modal
|
|
self.download_modal = None
|
|
|
|
layout.addWidget(self.checkbox)
|
|
layout.addLayout(content_layout)
|
|
layout.addStretch()
|
|
layout.addWidget(self.action_btn)
|
|
layout.addWidget(self.status_label)
|
|
|
|
def mousePressEvent(self, event):
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self.checkbox.setChecked(not self.checkbox.isChecked())
|
|
super().mousePressEvent(event)
|
|
|
|
def on_view_details_clicked(self):
|
|
"""Handle View Details button click"""
|
|
if self.playlist:
|
|
self.view_details_clicked.emit(self.playlist)
|
|
|
|
def show_operation_status(self, status_text):
|
|
"""Show operation status and hide action button"""
|
|
self.status_label.setText(status_text)
|
|
self.status_label.show()
|
|
self.action_btn.hide()
|
|
|
|
def hide_operation_status(self):
|
|
"""Hide operation status and show action button"""
|
|
self.status_label.hide()
|
|
self.action_btn.show()
|
|
|
|
def update_operation_status(self, status_text):
|
|
"""Update the operation status text"""
|
|
self.status_label.setText(status_text)
|
|
|
|
def set_download_modal(self, modal):
|
|
"""Store reference to the download modal"""
|
|
self.download_modal = modal
|
|
|
|
def on_status_clicked(self):
|
|
"""Handle status button click - reopen modal"""
|
|
if self.download_modal and not self.download_modal.isVisible():
|
|
self.download_modal.show()
|
|
self.download_modal.activateWindow()
|
|
self.download_modal.raise_()
|
|
|
|
class SyncOptionsPanel(QFrame):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setup_ui()
|
|
|
|
def setup_ui(self):
|
|
self.setStyleSheet("""
|
|
SyncOptionsPanel {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(20, 20, 20, 20)
|
|
layout.setSpacing(15)
|
|
|
|
# Title
|
|
title_label = QLabel("Sync Options")
|
|
title_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
|
title_label.setStyleSheet("color: #ffffff;")
|
|
|
|
# Download missing tracks option
|
|
self.download_missing = QCheckBox("Download missing tracks from Soulseek")
|
|
self.download_missing.setChecked(True)
|
|
self.download_missing.setStyleSheet("""
|
|
QCheckBox {
|
|
color: #ffffff;
|
|
font-size: 11px;
|
|
}
|
|
QCheckBox::indicator {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 8px;
|
|
border: 2px solid #b3b3b3;
|
|
background: transparent;
|
|
}
|
|
QCheckBox::indicator:checked {
|
|
background: #1db954;
|
|
border: 2px solid #1db954;
|
|
}
|
|
""")
|
|
|
|
# Quality selection
|
|
quality_layout = QHBoxLayout()
|
|
quality_label = QLabel("Preferred Quality:")
|
|
quality_label.setStyleSheet("color: #b3b3b3; font-size: 11px;")
|
|
|
|
self.quality_combo = QComboBox()
|
|
self.quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "Any"])
|
|
self.quality_combo.setCurrentText("FLAC")
|
|
self.quality_combo.setStyleSheet("""
|
|
QComboBox {
|
|
background: #404040;
|
|
border: 1px solid #606060;
|
|
border-radius: 4px;
|
|
padding: 5px;
|
|
color: #ffffff;
|
|
font-size: 11px;
|
|
}
|
|
QComboBox::drop-down {
|
|
border: none;
|
|
}
|
|
QComboBox::down-arrow {
|
|
image: none;
|
|
border: none;
|
|
}
|
|
""")
|
|
|
|
quality_layout.addWidget(quality_label)
|
|
quality_layout.addWidget(self.quality_combo)
|
|
quality_layout.addStretch()
|
|
|
|
layout.addWidget(title_label)
|
|
layout.addWidget(self.download_missing)
|
|
layout.addLayout(quality_layout)
|
|
|
|
class SyncPage(QWidget):
|
|
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, parent=None):
|
|
super().__init__(parent)
|
|
self.spotify_client = spotify_client
|
|
self.plex_client = plex_client
|
|
self.soulseek_client = soulseek_client
|
|
self.downloads_page = downloads_page
|
|
self.current_playlists = []
|
|
self.playlist_loader = None
|
|
|
|
# Track cache for performance
|
|
self.track_cache = {} # playlist_id -> tracks
|
|
|
|
# Thread pool for async operations (like downloads.py)
|
|
self.thread_pool = QThreadPool()
|
|
self.thread_pool.setMaxThreadCount(3) # Limit concurrent Spotify API calls
|
|
|
|
self.setup_ui()
|
|
|
|
# Don't auto-load on startup, but do auto-load when page becomes visible
|
|
self.show_initial_state()
|
|
self.playlists_loaded = False
|
|
|
|
def showEvent(self, event):
|
|
"""Auto-load playlists when page becomes visible (but not during app startup)"""
|
|
super().showEvent(event)
|
|
|
|
# Only auto-load once and only if we have a spotify client
|
|
if (not self.playlists_loaded and
|
|
self.spotify_client and
|
|
self.spotify_client.is_authenticated()):
|
|
|
|
# Small delay to ensure UI is fully rendered
|
|
QTimer.singleShot(100, self.auto_load_playlists)
|
|
|
|
def auto_load_playlists(self):
|
|
"""Auto-load playlists with proper UI transition"""
|
|
# Clear the welcome state first
|
|
self.clear_playlists()
|
|
|
|
# Start loading (this will set playlists_loaded = True)
|
|
self.load_playlists_async()
|
|
|
|
def show_initial_state(self):
|
|
"""Show initial state with option to load playlists"""
|
|
# Add welcome message to playlist area
|
|
welcome_message = QLabel("Ready to sync playlists!\nClick 'Load Playlists' to get started.")
|
|
welcome_message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
welcome_message.setStyleSheet("""
|
|
QLabel {
|
|
color: #b3b3b3;
|
|
font-size: 16px;
|
|
padding: 60px;
|
|
background: #282828;
|
|
border-radius: 12px;
|
|
border: 1px solid #404040;
|
|
line-height: 1.5;
|
|
}
|
|
""")
|
|
|
|
# Add load button
|
|
load_btn = QPushButton("🎵 Load Playlists")
|
|
load_btn.setFixedSize(200, 50)
|
|
load_btn.clicked.connect(self.load_playlists_async)
|
|
load_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: none;
|
|
border-radius: 25px;
|
|
color: #000000;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
margin-top: 20px;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
}
|
|
""")
|
|
|
|
# Add them to the playlist layout
|
|
if hasattr(self, 'playlist_layout'):
|
|
self.playlist_layout.addWidget(welcome_message)
|
|
self.playlist_layout.addWidget(load_btn)
|
|
self.playlist_layout.addStretch()
|
|
|
|
def setup_ui(self):
|
|
self.setStyleSheet("""
|
|
SyncPage {
|
|
background: #191414;
|
|
}
|
|
""")
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
main_layout.setContentsMargins(30, 30, 30, 30)
|
|
main_layout.setSpacing(25)
|
|
|
|
# Header
|
|
header = self.create_header()
|
|
main_layout.addWidget(header)
|
|
|
|
# Content area
|
|
content_layout = QHBoxLayout()
|
|
content_layout.setSpacing(15) # Reduced from 25 to 15 for tighter spacing
|
|
|
|
# Left side - Playlist list
|
|
playlist_section = self.create_playlist_section()
|
|
content_layout.addWidget(playlist_section, 2)
|
|
|
|
# Right side - Options and actions
|
|
right_sidebar = self.create_right_sidebar()
|
|
content_layout.addWidget(right_sidebar, 1)
|
|
|
|
main_layout.addLayout(content_layout, 1) # Allow content to stretch
|
|
|
|
def create_header(self):
|
|
header = QWidget()
|
|
layout = QVBoxLayout(header)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(5)
|
|
|
|
# Title
|
|
title_label = QLabel("Playlist Sync")
|
|
title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold))
|
|
title_label.setStyleSheet("color: #ffffff;")
|
|
|
|
# Subtitle
|
|
subtitle_label = QLabel("Synchronize your Spotify playlists with Plex")
|
|
subtitle_label.setFont(QFont("Arial", 14))
|
|
subtitle_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
layout.addWidget(title_label)
|
|
layout.addWidget(subtitle_label)
|
|
|
|
return header
|
|
|
|
def create_playlist_section(self):
|
|
section = QWidget()
|
|
layout = QVBoxLayout(section)
|
|
layout.setSpacing(15)
|
|
|
|
# Section header
|
|
header_layout = QHBoxLayout()
|
|
|
|
section_title = QLabel("Spotify Playlists")
|
|
section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
section_title.setStyleSheet("color: #ffffff;")
|
|
|
|
self.refresh_btn = QPushButton("🔄 Refresh")
|
|
self.refresh_btn.setFixedSize(100, 35)
|
|
self.refresh_btn.clicked.connect(self.load_playlists_async)
|
|
self.refresh_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: none;
|
|
border-radius: 17px;
|
|
color: #000000;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
}
|
|
QPushButton:pressed {
|
|
background: #1aa34a;
|
|
}
|
|
""")
|
|
|
|
header_layout.addWidget(section_title)
|
|
header_layout.addStretch()
|
|
header_layout.addWidget(self.refresh_btn)
|
|
|
|
# Playlist container
|
|
playlist_container = QScrollArea()
|
|
playlist_container.setWidgetResizable(True)
|
|
playlist_container.setStyleSheet("""
|
|
QScrollArea {
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
QScrollBar:vertical {
|
|
background: #282828;
|
|
width: 8px;
|
|
border-radius: 4px;
|
|
}
|
|
QScrollBar::handle:vertical {
|
|
background: #1db954;
|
|
border-radius: 4px;
|
|
}
|
|
""")
|
|
|
|
self.playlist_widget = QWidget()
|
|
self.playlist_layout = QVBoxLayout(self.playlist_widget)
|
|
self.playlist_layout.setSpacing(10)
|
|
|
|
# Playlists will be loaded asynchronously after UI setup
|
|
|
|
self.playlist_layout.addStretch()
|
|
playlist_container.setWidget(self.playlist_widget)
|
|
|
|
layout.addLayout(header_layout)
|
|
layout.addWidget(playlist_container)
|
|
|
|
return section
|
|
|
|
def create_right_sidebar(self):
|
|
section = QWidget()
|
|
layout = QVBoxLayout(section)
|
|
layout.setSpacing(20)
|
|
|
|
# Sync options
|
|
options_panel = SyncOptionsPanel()
|
|
layout.addWidget(options_panel)
|
|
|
|
# Action buttons
|
|
actions_frame = QFrame()
|
|
actions_frame.setStyleSheet("""
|
|
QFrame {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
|
|
actions_layout = QVBoxLayout(actions_frame)
|
|
actions_layout.setContentsMargins(20, 20, 20, 20)
|
|
actions_layout.setSpacing(15)
|
|
|
|
# Sync button
|
|
sync_btn = QPushButton("Start Sync")
|
|
sync_btn.setFixedHeight(45)
|
|
sync_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: none;
|
|
border-radius: 22px;
|
|
color: #000000;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
}
|
|
QPushButton:pressed {
|
|
background: #1aa34a;
|
|
}
|
|
""")
|
|
|
|
# Preview button
|
|
preview_btn = QPushButton("Preview Changes")
|
|
preview_btn.setFixedHeight(35)
|
|
preview_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: transparent;
|
|
border: 1px solid #1db954;
|
|
border-radius: 17px;
|
|
color: #1db954;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: rgba(29, 185, 84, 0.1);
|
|
}
|
|
""")
|
|
|
|
actions_layout.addWidget(sync_btn)
|
|
actions_layout.addWidget(preview_btn)
|
|
|
|
layout.addWidget(actions_frame)
|
|
|
|
# Progress section below buttons
|
|
progress_section = self.create_progress_section()
|
|
layout.addWidget(progress_section, 1) # Allow progress section to stretch
|
|
|
|
return section
|
|
|
|
def create_progress_section(self):
|
|
section = QFrame()
|
|
section.setMinimumHeight(200) # Set minimum height instead of fixed
|
|
section.setStyleSheet("""
|
|
QFrame {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(section)
|
|
layout.setContentsMargins(20, 15, 20, 15)
|
|
layout.setSpacing(10)
|
|
|
|
# Progress header
|
|
progress_header = QLabel("Sync Progress")
|
|
progress_header.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
|
progress_header.setStyleSheet("color: #ffffff;")
|
|
|
|
# Progress bar
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setFixedHeight(8)
|
|
self.progress_bar.setStyleSheet("""
|
|
QProgressBar {
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: #404040;
|
|
}
|
|
QProgressBar::chunk {
|
|
background: #1db954;
|
|
border-radius: 4px;
|
|
}
|
|
""")
|
|
|
|
# Progress text
|
|
self.progress_text = QLabel("Ready to sync...")
|
|
self.progress_text.setFont(QFont("Arial", 11))
|
|
self.progress_text.setStyleSheet("color: #b3b3b3;")
|
|
|
|
# Log area
|
|
self.log_area = QTextEdit()
|
|
self.log_area.setMinimumHeight(80) # Set minimum height instead of maximum
|
|
|
|
# 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()
|
|
|
|
# Add loading placeholder
|
|
loading_label = QLabel("🔄 Loading playlists...")
|
|
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
loading_label.setStyleSheet("""
|
|
QLabel {
|
|
color: #b3b3b3;
|
|
font-size: 14px;
|
|
padding: 40px;
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
self.playlist_layout.insertWidget(0, loading_label)
|
|
|
|
# Show loading state
|
|
self.refresh_btn.setText("🔄 Loading...")
|
|
self.refresh_btn.setEnabled(False)
|
|
self.log_area.append("Starting playlist loading...")
|
|
|
|
# Create and start loader thread
|
|
self.playlist_loader = PlaylistLoaderThread(self.spotify_client)
|
|
self.playlist_loader.playlist_loaded.connect(self.add_playlist_to_ui)
|
|
self.playlist_loader.loading_finished.connect(self.on_loading_finished)
|
|
self.playlist_loader.loading_failed.connect(self.on_loading_failed)
|
|
self.playlist_loader.progress_updated.connect(self.update_progress)
|
|
self.playlist_loader.start()
|
|
|
|
def add_playlist_to_ui(self, playlist):
|
|
"""Add a single playlist to the UI as it's loaded"""
|
|
# Simple sync status (placeholder for now)
|
|
sync_status = "Never Synced" # TODO: Check actual sync status
|
|
item = PlaylistItem(playlist.name, playlist.total_tracks, sync_status, playlist)
|
|
item.view_details_clicked.connect(self.show_playlist_details)
|
|
|
|
# Add subtle fade-in animation
|
|
item.setStyleSheet(item.styleSheet() + "background: rgba(40, 40, 40, 0);")
|
|
|
|
# Insert before the stretch item
|
|
self.playlist_layout.insertWidget(self.playlist_layout.count() - 1, item)
|
|
self.current_playlists.append(playlist)
|
|
|
|
# Animate the item appearing
|
|
self.animate_item_fade_in(item)
|
|
|
|
# Update log
|
|
self.log_area.append(f"Added playlist: {playlist.name} ({playlist.total_tracks} tracks)")
|
|
|
|
def animate_item_fade_in(self, item):
|
|
"""Add a subtle fade-in animation to playlist items"""
|
|
# Start with reduced opacity
|
|
item.setStyleSheet("""
|
|
PlaylistItem {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
opacity: 0.3;
|
|
}
|
|
PlaylistItem:hover {
|
|
background: #333333;
|
|
border: 1px solid #1db954;
|
|
}
|
|
""")
|
|
|
|
# Animate to full opacity after a short delay
|
|
QTimer.singleShot(50, lambda: item.setStyleSheet("""
|
|
PlaylistItem {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
PlaylistItem:hover {
|
|
background: #333333;
|
|
border: 1px solid #1db954;
|
|
}
|
|
"""))
|
|
|
|
def on_loading_finished(self, count):
|
|
"""Handle completion of playlist loading"""
|
|
# Remove loading placeholder if it exists
|
|
for i in range(self.playlist_layout.count()):
|
|
item = self.playlist_layout.itemAt(i)
|
|
if item and item.widget() and isinstance(item.widget(), QLabel):
|
|
if "Loading playlists" in item.widget().text():
|
|
item.widget().deleteLater()
|
|
break
|
|
|
|
self.refresh_btn.setText("🔄 Refresh")
|
|
self.refresh_btn.setEnabled(True)
|
|
self.log_area.append(f"✓ Loaded {count} Spotify playlists successfully")
|
|
|
|
# Start background preloading of tracks for smaller playlists
|
|
self.start_background_preloading()
|
|
|
|
def start_background_preloading(self):
|
|
"""Start background preloading of tracks for smaller playlists"""
|
|
if not self.spotify_client:
|
|
return
|
|
|
|
# Preload tracks for playlists with < 100 tracks to improve responsiveness
|
|
for playlist in self.current_playlists:
|
|
if (playlist.total_tracks < 100 and
|
|
playlist.id not in self.track_cache and
|
|
not playlist.tracks):
|
|
|
|
# Create background worker
|
|
worker = TrackLoadingWorker(self.spotify_client, playlist.id, playlist.name)
|
|
worker.signals.tracks_loaded.connect(self.on_background_tracks_loaded)
|
|
# Don't connect error signals for background loading to avoid spam
|
|
|
|
# Submit with low priority
|
|
self.thread_pool.start(worker)
|
|
|
|
# Add delay between requests to be nice to Spotify API
|
|
QTimer.singleShot(2000, lambda: None) # 2 second delay
|
|
|
|
def on_background_tracks_loaded(self, playlist_id, tracks):
|
|
"""Handle background track loading completion"""
|
|
# Cache the tracks for future use
|
|
self.track_cache[playlist_id] = tracks
|
|
|
|
# Update the playlist object if we can find it
|
|
for playlist in self.current_playlists:
|
|
if playlist.id == playlist_id:
|
|
playlist.tracks = tracks
|
|
break
|
|
|
|
def on_loading_failed(self, error_msg):
|
|
"""Handle playlist loading failure"""
|
|
# Remove loading placeholder if it exists
|
|
for i in range(self.playlist_layout.count()):
|
|
item = self.playlist_layout.itemAt(i)
|
|
if item and item.widget() and isinstance(item.widget(), QLabel):
|
|
if "Loading playlists" in item.widget().text():
|
|
item.widget().deleteLater()
|
|
break
|
|
|
|
self.refresh_btn.setText("🔄 Refresh")
|
|
self.refresh_btn.setEnabled(True)
|
|
self.log_area.append(f"✗ Failed to load playlists: {error_msg}")
|
|
QMessageBox.critical(self, "Error", f"Failed to load playlists: {error_msg}")
|
|
|
|
def update_progress(self, message):
|
|
"""Update progress text"""
|
|
self.log_area.append(message)
|
|
|
|
def disable_refresh_button(self, operation_name="Operation"):
|
|
"""Disable refresh button during sync/download operations"""
|
|
self.refresh_btn.setEnabled(False)
|
|
self.refresh_btn.setText(f"🔄 {operation_name}...")
|
|
|
|
def enable_refresh_button(self):
|
|
"""Re-enable refresh button after operations complete"""
|
|
self.refresh_btn.setEnabled(True)
|
|
self.refresh_btn.setText("🔄 Refresh")
|
|
|
|
def load_initial_playlists(self):
|
|
"""Load initial playlist data (placeholder or real)"""
|
|
if self.spotify_client and self.spotify_client.is_authenticated():
|
|
self.refresh_playlists()
|
|
else:
|
|
# Show placeholder playlists
|
|
playlists = [
|
|
("Liked Songs", 247, "Synced"),
|
|
("Discover Weekly", 30, "Needs Sync"),
|
|
("Chill Vibes", 89, "Synced"),
|
|
("Workout Mix", 156, "Needs Sync"),
|
|
("Road Trip", 67, "Never Synced"),
|
|
("Focus Music", 45, "Synced")
|
|
]
|
|
|
|
for name, count, status in playlists:
|
|
item = PlaylistItem(name, count, status, None) # No playlist object for placeholders
|
|
self.playlist_layout.addWidget(item)
|
|
|
|
def refresh_playlists(self):
|
|
"""Refresh playlists from Spotify API using async loader"""
|
|
if not self.spotify_client:
|
|
QMessageBox.warning(self, "Error", "Spotify client not available")
|
|
return
|
|
|
|
if not self.spotify_client.is_authenticated():
|
|
QMessageBox.warning(self, "Error", "Spotify not authenticated. Please check your settings.")
|
|
return
|
|
|
|
# Use the async loader
|
|
self.load_playlists_async()
|
|
|
|
def show_playlist_details(self, playlist):
|
|
"""Show playlist details modal"""
|
|
if playlist:
|
|
modal = PlaylistDetailsModal(playlist, self)
|
|
modal.exec()
|
|
|
|
def clear_playlists(self):
|
|
"""Clear all playlist items from the layout"""
|
|
# Clear the current playlists list
|
|
self.current_playlists = []
|
|
|
|
# Remove all items including welcome state
|
|
for i in reversed(range(self.playlist_layout.count())):
|
|
item = self.playlist_layout.itemAt(i)
|
|
if item.widget():
|
|
item.widget().deleteLater()
|
|
elif item.spacerItem():
|
|
continue # Keep the stretch spacer
|
|
else:
|
|
self.playlist_layout.removeItem(item)
|
|
|
|
|
|
class 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
|
|
self.soulseek_client = parent_modal.parent_page.soulseek_client
|
|
self.downloads_page = parent_modal.downloads_page
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
if not self.failed_tracks:
|
|
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
|
|
|
|
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
|
|
|
|
def load_current_track(self):
|
|
"""Loads the current failed track's info and intelligently triggers a search."""
|
|
self.cancel_current_search()
|
|
self.clear_results()
|
|
|
|
# Sync with the parent modal's live list of failed tracks
|
|
self._update_track_list()
|
|
|
|
if not self.failed_tracks:
|
|
QMessageBox.information(self, "Complete", "All failed tracks have been addressed.")
|
|
self.accept()
|
|
return
|
|
|
|
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"
|
|
|
|
self.info_label.setText(f"Could not find: <b>{spotify_track.name}</b><br>by {artist}")
|
|
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."""
|
|
if self.current_track_index < len(self.parent_modal.permanently_failed_tracks) - 1:
|
|
self.current_track_index += 1
|
|
self.load_current_track()
|
|
|
|
def load_previous_track(self):
|
|
"""Navigate to the previous failed track."""
|
|
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."""
|
|
total_tracks = len(self.parent_modal.permanently_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)
|
|
|
|
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"""
|
|
|
|
def __init__(self, playlist, parent_page, sync_modal, downloads_page):
|
|
print(f"🏗️ Initializing DownloadMissingTracksModal...")
|
|
super().__init__(sync_modal)
|
|
self.playlist = playlist
|
|
self.parent_page = parent_page
|
|
self.sync_modal = sync_modal
|
|
self.downloads_page = downloads_page
|
|
|
|
# State tracking
|
|
self.total_tracks = len(playlist.tracks)
|
|
self.matched_tracks_count = 0
|
|
self.tracks_to_download_count = 0
|
|
self.downloaded_tracks_count = 0
|
|
self.analysis_complete = False
|
|
self.download_in_progress = False
|
|
|
|
# --- ADD THIS LINE ---
|
|
self.permanently_failed_tracks = []
|
|
# --- END OF ADDITION ---
|
|
|
|
print(f"📊 Total tracks: {self.total_tracks}")
|
|
|
|
# Track analysis results
|
|
self.analysis_results = []
|
|
self.missing_tracks = []
|
|
|
|
# Worker tracking
|
|
self.active_workers = []
|
|
self.fallback_pools = []
|
|
|
|
# --- NEW CODE FOR 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) # Check statuses every 2 seconds
|
|
|
|
self.active_downloads = [] # This will hold info about tracks being downloaded
|
|
# --- END OF NEW CODE ---
|
|
|
|
print("🎨 Setting up UI...")
|
|
self.setup_ui()
|
|
print("✅ Modal initialization complete")
|
|
|
|
def setup_ui(self):
|
|
"""Set up the enhanced modal UI"""
|
|
self.setWindowTitle(f"Download Missing Tracks - {self.playlist.name}")
|
|
self.resize(1200, 900) # Larger size
|
|
self.setModal(True)
|
|
|
|
# Set window flags for proper dialog behavior
|
|
self.setWindowFlags(Qt.WindowType.Dialog)
|
|
|
|
# Improved dark theme styling
|
|
self.setStyleSheet("""
|
|
QDialog {
|
|
background-color: #1e1e1e;
|
|
color: #ffffff;
|
|
}
|
|
QLabel {
|
|
color: #ffffff;
|
|
}
|
|
QPushButton {
|
|
background-color: #1db954;
|
|
color: #000000;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-weight: bold;
|
|
padding: 10px 20px;
|
|
min-width: 100px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #1ed760;
|
|
}
|
|
QPushButton:disabled {
|
|
background-color: #404040;
|
|
color: #888888;
|
|
}
|
|
""")
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
main_layout.setContentsMargins(25, 25, 25, 25)
|
|
main_layout.setSpacing(15)
|
|
|
|
# Compact header with dashboard in same row
|
|
top_section = self.create_compact_top_section()
|
|
main_layout.addWidget(top_section)
|
|
|
|
# Progress bars section (compact)
|
|
progress_section = self.create_progress_section()
|
|
main_layout.addWidget(progress_section)
|
|
|
|
# Track table (main focus - takes most space)
|
|
table_section = self.create_track_table()
|
|
main_layout.addWidget(table_section, stretch=1) # Give it all available space
|
|
|
|
# Button controls
|
|
button_section = self.create_buttons()
|
|
main_layout.addWidget(button_section)
|
|
|
|
def create_compact_top_section(self):
|
|
"""Create compact top section with header and dashboard combined"""
|
|
top_frame = QFrame()
|
|
top_frame.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #2d2d2d;
|
|
border: 1px solid #444444;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(top_frame)
|
|
layout.setSpacing(15)
|
|
|
|
# Header row
|
|
header_layout = QHBoxLayout()
|
|
|
|
# Left side - Title and subtitle
|
|
title_section = QVBoxLayout()
|
|
title_section.setSpacing(2)
|
|
|
|
title = QLabel("Download Missing Tracks")
|
|
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
title.setStyleSheet("color: #1db954;")
|
|
|
|
subtitle = QLabel(f"Playlist: {self.playlist.name}")
|
|
subtitle.setFont(QFont("Arial", 11))
|
|
subtitle.setStyleSheet("color: #aaaaaa;")
|
|
|
|
title_section.addWidget(title)
|
|
title_section.addWidget(subtitle)
|
|
|
|
# Right side - Dashboard counters (horizontal)
|
|
dashboard_layout = QHBoxLayout()
|
|
dashboard_layout.setSpacing(20)
|
|
|
|
# Total Tracks
|
|
self.total_card = self.create_compact_counter_card("📀 Total", str(self.total_tracks), "#1db954")
|
|
|
|
# Matched Tracks
|
|
self.matched_card = self.create_compact_counter_card("✅ Found", "0", "#4CAF50")
|
|
|
|
# To Download
|
|
self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b")
|
|
|
|
# Downloaded
|
|
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_dashboard(self):
|
|
"""Create dashboard with live counters"""
|
|
dashboard_frame = QFrame()
|
|
dashboard_frame.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #404040;
|
|
border: 1px solid #555555;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
margin-bottom: 5px;
|
|
}
|
|
""")
|
|
|
|
layout = QHBoxLayout(dashboard_frame)
|
|
layout.setSpacing(30)
|
|
|
|
# Total Tracks
|
|
self.total_card = self.create_counter_card("📀 Total Tracks", str(self.total_tracks), "#1db954")
|
|
|
|
# Matched Tracks
|
|
self.matched_card = self.create_counter_card("✅ Matched", "0", "#1ed760")
|
|
|
|
# To Download
|
|
self.download_card = self.create_counter_card("⬇️ To Download", "0", "#ff6b6b")
|
|
|
|
layout.addWidget(self.total_card)
|
|
layout.addWidget(self.matched_card)
|
|
layout.addWidget(self.download_card)
|
|
layout.addStretch()
|
|
|
|
return dashboard_frame
|
|
|
|
def create_compact_counter_card(self, title, count, color):
|
|
"""Create a compact counter card widget"""
|
|
card = QFrame()
|
|
card.setStyleSheet(f"""
|
|
QFrame {{
|
|
background-color: #3a3a3a;
|
|
border: 2px solid {color};
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
min-width: 80px;
|
|
}}
|
|
""")
|
|
|
|
layout = QVBoxLayout(card)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(2)
|
|
|
|
count_label = QLabel(count)
|
|
count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
count_label.setStyleSheet(f"color: {color}; background: transparent;")
|
|
count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
title_label = QLabel(title)
|
|
title_label.setFont(QFont("Arial", 9))
|
|
title_label.setStyleSheet("color: #cccccc; background: transparent;")
|
|
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
layout.addWidget(count_label)
|
|
layout.addWidget(title_label)
|
|
|
|
# Store references for updates
|
|
if "Total" in title:
|
|
self.total_count_label = count_label
|
|
elif "Found" in title:
|
|
self.matched_count_label = count_label
|
|
elif "Missing" in title:
|
|
self.download_count_label = count_label
|
|
elif "Downloaded" in title:
|
|
self.downloaded_count_label = count_label
|
|
|
|
return card
|
|
|
|
def create_counter_card(self, title, count, color):
|
|
"""Create a counter card widget"""
|
|
card = QFrame()
|
|
card.setStyleSheet(f"""
|
|
QFrame {{
|
|
background-color: #333333;
|
|
border: 2px solid {color};
|
|
border-radius: 6px;
|
|
padding: 10px;
|
|
min-width: 120px;
|
|
}}
|
|
""")
|
|
|
|
layout = QVBoxLayout(card)
|
|
layout.setSpacing(5)
|
|
|
|
title_label = QLabel(title)
|
|
title_label.setFont(QFont("Arial", 10))
|
|
title_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
count_label = QLabel(count)
|
|
count_label.setFont(QFont("Arial", 20, QFont.Weight.Bold))
|
|
count_label.setStyleSheet(f"color: {color};")
|
|
count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
layout.addWidget(title_label)
|
|
layout.addWidget(count_label)
|
|
|
|
# Store references for updates
|
|
if "Total" in title:
|
|
self.total_count_label = count_label
|
|
elif "Matched" in title:
|
|
self.matched_count_label = count_label
|
|
elif "Download" in title:
|
|
self.download_count_label = count_label
|
|
|
|
return card
|
|
|
|
def create_progress_section(self):
|
|
"""Create compact dual progress bar section"""
|
|
progress_frame = QFrame()
|
|
progress_frame.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #2d2d2d;
|
|
border: 1px solid #444444;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(progress_frame)
|
|
layout.setSpacing(8)
|
|
|
|
# Plex Analysis Progress
|
|
analysis_container = QVBoxLayout()
|
|
analysis_container.setSpacing(4)
|
|
|
|
analysis_label = QLabel("🔍 Plex Analysis")
|
|
analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
|
analysis_label.setStyleSheet("color: #cccccc;")
|
|
|
|
self.analysis_progress = QProgressBar()
|
|
self.analysis_progress.setFixedHeight(20)
|
|
self.analysis_progress.setStyleSheet("""
|
|
QProgressBar {
|
|
border: 1px solid #555555;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
background-color: #444444;
|
|
color: #ffffff;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
}
|
|
QProgressBar::chunk {
|
|
background-color: #1db954;
|
|
border-radius: 9px;
|
|
}
|
|
""")
|
|
self.analysis_progress.setVisible(False)
|
|
|
|
analysis_container.addWidget(analysis_label)
|
|
analysis_container.addWidget(self.analysis_progress)
|
|
|
|
# Download Progress
|
|
download_container = QVBoxLayout()
|
|
download_container.setSpacing(4)
|
|
|
|
download_label = QLabel("⬇️ Download Progress")
|
|
download_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
|
download_label.setStyleSheet("color: #cccccc;")
|
|
|
|
self.download_progress = QProgressBar()
|
|
self.download_progress.setFixedHeight(20)
|
|
self.download_progress.setStyleSheet("""
|
|
QProgressBar {
|
|
border: 1px solid #555555;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
background-color: #444444;
|
|
color: #ffffff;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
}
|
|
QProgressBar::chunk {
|
|
background-color: #ff6b6b;
|
|
border-radius: 9px;
|
|
}
|
|
""")
|
|
self.download_progress.setVisible(False)
|
|
|
|
download_container.addWidget(download_label)
|
|
download_container.addWidget(self.download_progress)
|
|
|
|
layout.addLayout(analysis_container)
|
|
layout.addLayout(download_container)
|
|
|
|
return progress_frame
|
|
|
|
def create_track_table(self):
|
|
"""Create enhanced track table with Matched/Downloaded columns"""
|
|
table_frame = QFrame()
|
|
table_frame.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #2d2d2d;
|
|
border: 1px solid #444444;
|
|
border-radius: 8px;
|
|
padding: 0px;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(table_frame)
|
|
layout.setContentsMargins(15, 15, 15, 15) # Internal padding for spacing
|
|
layout.setSpacing(10)
|
|
|
|
# Table header
|
|
header_label = QLabel("📋 Track Analysis")
|
|
header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold))
|
|
header_label.setStyleSheet("color: #ffffff; padding: 5px;")
|
|
|
|
# Create custom header row instead of table header
|
|
custom_header = self.create_custom_header()
|
|
|
|
# Create table WITHOUT header
|
|
self.track_table = QTableWidget()
|
|
self.track_table.setColumnCount(5)
|
|
self.track_table.horizontalHeader().setVisible(False) # Hide the problematic header
|
|
|
|
# Clean table styling (no header needed now)
|
|
self.track_table.setStyleSheet("""
|
|
QTableWidget {
|
|
background-color: #3a3a3a;
|
|
alternate-background-color: #424242;
|
|
selection-background-color: #1db954;
|
|
selection-color: #000000;
|
|
gridline-color: #555555;
|
|
color: #ffffff;
|
|
border: 1px solid #555555;
|
|
border-top: none;
|
|
font-size: 12px;
|
|
}
|
|
QTableWidget::item {
|
|
padding: 12px 8px;
|
|
border-bottom: 1px solid #4a4a4a;
|
|
}
|
|
QTableWidget::item:selected {
|
|
background-color: #1db954;
|
|
color: #000000;
|
|
}
|
|
""")
|
|
|
|
# Configure column sizes to EXACTLY match custom header
|
|
header = self.track_table.horizontalHeader()
|
|
|
|
# Set fixed columns first to match header exactly
|
|
self.track_table.setColumnWidth(2, 90) # Duration - fixed
|
|
self.track_table.setColumnWidth(3, 140) # Matched - fixed
|
|
|
|
# Configure resize modes for proper alignment
|
|
# Two stretching columns (Track, Artist) and one last section stretch (Downloaded)
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Track - flexible
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Artist - flexible
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Duration - fixed 90px
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Matched - fixed 140px
|
|
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # Downloaded - will be handled by setStretchLastSection
|
|
|
|
# Let the last section (Downloaded) stretch to fill remaining space
|
|
header.setStretchLastSection(True)
|
|
|
|
# Better table behavior
|
|
self.track_table.setAlternatingRowColors(True)
|
|
self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
self.track_table.setShowGrid(True)
|
|
self.track_table.setGridStyle(Qt.PenStyle.SolidLine)
|
|
|
|
# Set row height for better readability
|
|
self.track_table.verticalHeader().setDefaultSectionSize(35)
|
|
self.track_table.verticalHeader().setVisible(False)
|
|
|
|
# Populate with initial track data
|
|
self.populate_track_table()
|
|
|
|
layout.addWidget(header_label)
|
|
layout.addWidget(custom_header)
|
|
layout.addWidget(self.track_table)
|
|
|
|
return table_frame
|
|
|
|
def create_custom_header(self):
|
|
"""Create a custom header row with visible labels"""
|
|
header_frame = QFrame()
|
|
header_frame.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #1db954;
|
|
border: 1px solid #169441;
|
|
border-radius: 6px;
|
|
padding: 0px;
|
|
margin: 0px;
|
|
}
|
|
""")
|
|
|
|
layout = QHBoxLayout(header_frame)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(1)
|
|
|
|
# Create header labels with same proportions as table columns
|
|
headers = ["Track", "Artist", "Duration", "Matched", "Downloaded"]
|
|
|
|
# Track - stretch
|
|
track_label = QLabel("Track")
|
|
track_label.setStyleSheet("""
|
|
QLabel {
|
|
background-color: transparent;
|
|
color: #000000;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
padding: 12px 8px;
|
|
border-right: 1px solid #169441;
|
|
}
|
|
""")
|
|
track_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
# Artist - stretch
|
|
artist_label = QLabel("Artist")
|
|
artist_label.setStyleSheet("""
|
|
QLabel {
|
|
background-color: transparent;
|
|
color: #000000;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
padding: 12px 8px;
|
|
border-right: 1px solid #169441;
|
|
}
|
|
""")
|
|
artist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
# Duration - fixed width
|
|
duration_label = QLabel("Duration")
|
|
duration_label.setFixedWidth(90)
|
|
duration_label.setStyleSheet("""
|
|
QLabel {
|
|
background-color: transparent;
|
|
color: #000000;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
padding: 12px 8px;
|
|
border-right: 1px solid #169441;
|
|
}
|
|
""")
|
|
duration_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
# Matched - fixed width
|
|
matched_label = QLabel("Matched")
|
|
matched_label.setFixedWidth(140)
|
|
matched_label.setStyleSheet("""
|
|
QLabel {
|
|
background-color: transparent;
|
|
color: #000000;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
padding: 12px 8px;
|
|
border-right: 1px solid #169441;
|
|
}
|
|
""")
|
|
matched_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
# Downloaded - stretch to fill remaining space
|
|
downloaded_label = QLabel("Downloaded")
|
|
downloaded_label.setStyleSheet("""
|
|
QLabel {
|
|
background-color: transparent;
|
|
color: #000000;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
padding: 12px 8px;
|
|
}
|
|
""")
|
|
downloaded_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
layout.addWidget(track_label, stretch=1)
|
|
layout.addWidget(artist_label, stretch=1)
|
|
layout.addWidget(duration_label)
|
|
layout.addWidget(matched_label)
|
|
layout.addWidget(downloaded_label, stretch=1) # Let it stretch to fill remaining space
|
|
|
|
return header_frame
|
|
|
|
def populate_track_table(self):
|
|
"""Populate track table with playlist tracks"""
|
|
self.track_table.setRowCount(len(self.playlist.tracks))
|
|
|
|
for i, track in enumerate(self.playlist.tracks):
|
|
# Track name
|
|
track_item = QTableWidgetItem(track.name)
|
|
track_item.setFlags(track_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(i, 0, track_item)
|
|
|
|
# Artist
|
|
artist_name = track.artists[0] if track.artists else "Unknown Artist"
|
|
artist_item = QTableWidgetItem(artist_name)
|
|
artist_item.setFlags(artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self.track_table.setItem(i, 1, artist_item)
|
|
|
|
# Duration
|
|
duration = self.format_duration(track.duration_ms)
|
|
duration_item = QTableWidgetItem(duration)
|
|
duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(i, 2, duration_item)
|
|
|
|
# Matched status (initially pending)
|
|
matched_item = QTableWidgetItem("⏳ Pending")
|
|
matched_item.setFlags(matched_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(i, 3, matched_item)
|
|
|
|
# Downloaded status (initially pending)
|
|
downloaded_item = QTableWidgetItem("⏳ Pending")
|
|
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(i, 4, downloaded_item)
|
|
|
|
def format_duration(self, duration_ms):
|
|
"""Convert milliseconds to MM:SS format"""
|
|
seconds = duration_ms // 1000
|
|
minutes = seconds // 60
|
|
seconds = seconds % 60
|
|
return f"{minutes}:{seconds:02d}"
|
|
|
|
def create_buttons(self):
|
|
"""Create improved button section"""
|
|
button_frame = QFrame()
|
|
button_frame.setStyleSheet("""
|
|
QFrame {
|
|
background-color: transparent;
|
|
padding: 10px;
|
|
}
|
|
""")
|
|
|
|
layout = QHBoxLayout(button_frame)
|
|
layout.setSpacing(15)
|
|
layout.setContentsMargins(0, 10, 0, 0)
|
|
|
|
# --- ADD THE NEW BUTTON DEFINITION HERE ---
|
|
self.correct_failed_btn = QPushButton("🔧 Correct Failed Matches")
|
|
self.correct_failed_btn.setFixedSize(220, 40) # Slightly wider for counter text
|
|
self.correct_failed_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #ffc107; /* Amber color */
|
|
color: #000000;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-weight: bold;
|
|
padding: 10px 20px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #ffca28;
|
|
}
|
|
""")
|
|
self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked)
|
|
self.correct_failed_btn.hide() # Initially hidden
|
|
# --- END OF ADDITION ---
|
|
|
|
# Begin Search button
|
|
self.begin_search_btn = QPushButton("Begin Search")
|
|
self.begin_search_btn.setFixedSize(160, 40)
|
|
self.begin_search_btn.clicked.connect(self.on_begin_search_clicked)
|
|
|
|
# Cancel button
|
|
self.cancel_btn = QPushButton("Cancel")
|
|
self.cancel_btn.setFixedSize(110, 40)
|
|
self.cancel_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #d32f2f;
|
|
color: #ffffff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-weight: bold;
|
|
padding: 10px 20px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #f44336;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #b71c1c;
|
|
}
|
|
""")
|
|
self.cancel_btn.clicked.connect(self.on_cancel_clicked)
|
|
self.cancel_btn.hide() # Initially hidden
|
|
|
|
# Close button
|
|
self.close_btn = QPushButton("Close")
|
|
self.close_btn.setFixedSize(110, 40)
|
|
self.close_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #616161;
|
|
color: #ffffff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-weight: bold;
|
|
padding: 10px 20px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #757575;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #424242;
|
|
}
|
|
""")
|
|
self.close_btn.clicked.connect(self.on_close_clicked)
|
|
|
|
layout.addStretch()
|
|
layout.addWidget(self.begin_search_btn)
|
|
layout.addWidget(self.cancel_btn)
|
|
# --- ADD THE BUTTON TO THE LAYOUT ---
|
|
layout.addWidget(self.correct_failed_btn)
|
|
# --- END OF ADDITION ---
|
|
layout.addWidget(self.close_btn)
|
|
|
|
return button_frame
|
|
|
|
def on_begin_search_clicked(self):
|
|
"""Handle Begin Search button click - starts Plex analysis"""
|
|
# Hide Begin Search button and show Cancel button
|
|
self.begin_search_btn.hide()
|
|
self.cancel_btn.show()
|
|
|
|
# Show and reset analysis progress bar
|
|
self.analysis_progress.setVisible(True)
|
|
self.analysis_progress.setMaximum(self.total_tracks)
|
|
self.analysis_progress.setValue(0)
|
|
|
|
# Update playlist status indicator
|
|
playlist_item = self.find_playlist_item()
|
|
if playlist_item:
|
|
playlist_item.show_operation_status("🔍 Starting analysis...")
|
|
|
|
# Disable refresh button during operations
|
|
if hasattr(self.parent_page, 'disable_refresh_button'):
|
|
self.parent_page.disable_refresh_button("Analyzing")
|
|
|
|
# Start Plex analysis
|
|
self.start_plex_analysis()
|
|
|
|
def start_plex_analysis(self):
|
|
"""Start Plex analysis using existing worker"""
|
|
plex_client = getattr(self.parent_page, 'plex_client', None)
|
|
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, plex_client)
|
|
|
|
# Connect signals for live updates
|
|
worker.signals.analysis_started.connect(self.on_analysis_started)
|
|
worker.signals.track_analyzed.connect(self.on_track_analyzed)
|
|
worker.signals.analysis_completed.connect(self.on_analysis_completed)
|
|
worker.signals.analysis_failed.connect(self.on_analysis_failed)
|
|
|
|
# Track worker for cleanup
|
|
self.active_workers.append(worker)
|
|
|
|
# Submit to thread pool
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
# Create fallback thread pool
|
|
thread_pool = QThreadPool()
|
|
self.fallback_pools.append(thread_pool)
|
|
thread_pool.start(worker)
|
|
|
|
def on_analysis_started(self, total_tracks):
|
|
"""Handle analysis start"""
|
|
print(f"🔍 Analysis started for {total_tracks} tracks")
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f"🔍 Starting Plex analysis for {total_tracks} tracks...")
|
|
|
|
def on_track_analyzed(self, track_index, result):
|
|
"""Handle individual track analysis completion with live UI updates"""
|
|
# Update progress bar
|
|
self.analysis_progress.setValue(track_index)
|
|
|
|
# Update counters and table
|
|
if result.exists_in_plex:
|
|
# Track found in Plex
|
|
matched_text = f"✅ Found ({result.confidence:.1f})"
|
|
self.matched_tracks_count += 1
|
|
self.matched_count_label.setText(str(self.matched_tracks_count))
|
|
else:
|
|
# Track missing from Plex - will need download
|
|
matched_text = "❌ Missing"
|
|
self.tracks_to_download_count += 1
|
|
self.download_count_label.setText(str(self.tracks_to_download_count))
|
|
|
|
# Update table row
|
|
matched_item = QTableWidgetItem(matched_text)
|
|
matched_item.setFlags(matched_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(track_index - 1, 3, matched_item)
|
|
|
|
# Update main console log every 10 tracks or significant findings
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
track_name = result.spotify_track.name
|
|
artist_name = result.spotify_track.artists[0] if result.spotify_track.artists else "Unknown"
|
|
|
|
# Log every 10 tracks
|
|
if track_index % 10 == 0:
|
|
progress_pct = (track_index / len(self.playlist.tracks)) * 100
|
|
self.parent_page.log_area.append(f"⏳ Analyzed {track_index}/{len(self.playlist.tracks)} tracks ({progress_pct:.0f}%) - {self.matched_tracks_count} found, {self.tracks_to_download_count} missing")
|
|
|
|
# Log specific track info for missing tracks
|
|
elif not result.exists_in_plex:
|
|
self.parent_page.log_area.append(f"❌ Missing: {track_name} by {artist_name}")
|
|
|
|
# Log high confidence matches occasionally
|
|
elif result.confidence >= 0.95 and track_index % 20 == 0:
|
|
self.parent_page.log_area.append(f"✅ High confidence match: {track_name} ({result.confidence:.1f})")
|
|
|
|
# Update playlist status indicator
|
|
playlist_item = self.find_playlist_item()
|
|
if playlist_item:
|
|
total = len(self.playlist.tracks)
|
|
status_text = f"🔍 Analyzing {track_index}/{total}"
|
|
playlist_item.update_operation_status(status_text)
|
|
|
|
print(f" Track {track_index}: {result.spotify_track.name} - {'Found' if result.exists_in_plex else 'Missing'}")
|
|
|
|
def on_analysis_completed(self, results):
|
|
"""Handle analysis completion"""
|
|
self.analysis_complete = True
|
|
self.analysis_results = results
|
|
self.missing_tracks = [r for r in results if not r.exists_in_plex]
|
|
|
|
print(f"✅ Analysis complete: {len(self.missing_tracks)} to download, {self.matched_tracks_count} matched")
|
|
|
|
# Update main console log with analysis summary
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
total_tracks = len(results)
|
|
matched_count = len([r for r in results if r.exists_in_plex])
|
|
missing_count = len(self.missing_tracks)
|
|
|
|
self.parent_page.log_area.append(f"✅ Plex analysis complete: {matched_count}/{total_tracks} tracks found in library")
|
|
|
|
if missing_count > 0:
|
|
self.parent_page.log_area.append(f"⏬ Preparing to download {missing_count} missing tracks...")
|
|
else:
|
|
self.parent_page.log_area.append(f"🎉 All tracks already exist in Plex - no downloads needed!")
|
|
|
|
if self.missing_tracks:
|
|
# Update playlist status for download phase
|
|
playlist_item = self.find_playlist_item()
|
|
if playlist_item:
|
|
status_text = f"⏬ Starting downloads..."
|
|
playlist_item.update_operation_status(status_text)
|
|
|
|
# Update refresh button text for download phase
|
|
if hasattr(self.parent_page, 'disable_refresh_button'):
|
|
self.parent_page.disable_refresh_button("Downloading")
|
|
|
|
# Automatically start download progress
|
|
self.start_download_progress()
|
|
else:
|
|
# All tracks found - hide Cancel button and update status
|
|
self.cancel_btn.hide()
|
|
playlist_item = self.find_playlist_item()
|
|
if playlist_item:
|
|
playlist_item.hide_operation_status()
|
|
|
|
# Re-enable refresh button - operations complete
|
|
if hasattr(self.parent_page, 'enable_refresh_button'):
|
|
self.parent_page.enable_refresh_button()
|
|
|
|
QMessageBox.information(self, "Analysis Complete",
|
|
"All tracks already exist in Plex library!\nNo downloads needed.")
|
|
|
|
def on_analysis_failed(self, error_message):
|
|
"""Handle analysis failure"""
|
|
print(f"❌ Analysis failed: {error_message}")
|
|
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
|
|
|
|
# Reset UI - show Begin Search again, hide Cancel
|
|
self.cancel_btn.hide()
|
|
self.begin_search_btn.show()
|
|
self.begin_search_btn.setEnabled(True)
|
|
self.begin_search_btn.setText("Begin Search")
|
|
self.analysis_progress.setVisible(False)
|
|
|
|
# Re-enable refresh button - operation failed
|
|
if hasattr(self.parent_page, 'enable_refresh_button'):
|
|
self.parent_page.enable_refresh_button()
|
|
|
|
def start_download_progress(self):
|
|
"""Start actual download progress tracking"""
|
|
print(f"🚀 Starting download progress for {len(self.missing_tracks)} tracks")
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f"🚀 Starting Soulseek downloads for {len(self.missing_tracks)} missing tracks...")
|
|
|
|
# Show download progress bar
|
|
self.download_progress.setVisible(True)
|
|
self.download_progress.setMaximum(len(self.missing_tracks))
|
|
self.download_progress.setValue(0)
|
|
|
|
# Start real downloads
|
|
self.start_soulseek_downloads()
|
|
|
|
def start_soulseek_downloads(self):
|
|
"""Start real Soulseek downloads for missing tracks using existing downloads.py infrastructure"""
|
|
if not self.missing_tracks:
|
|
return
|
|
|
|
# Check downloads page availability
|
|
if not self.downloads_page:
|
|
QMessageBox.critical(self, "Downloads Page Unavailable",
|
|
"Downloads page not available. Please restart the application.")
|
|
return
|
|
|
|
# Start download process
|
|
self.download_in_progress = True
|
|
self.current_download = 0
|
|
|
|
# Start downloading tracks using PARALLEL downloads infrastructure
|
|
print("🚀 Starting parallel download system...")
|
|
self.start_parallel_downloads()
|
|
|
|
def download_missing_tracks_with_infrastructure(self):
|
|
"""DEPRECATED - now redirects to parallel download system"""
|
|
print("⚠️ download_missing_tracks_with_infrastructure() called - switching to parallel system")
|
|
if not self.missing_tracks:
|
|
self.on_all_downloads_complete()
|
|
return
|
|
|
|
# Redirect to parallel download system
|
|
self.start_parallel_downloads()
|
|
self.failed_downloads = 0
|
|
self.completed_downloads = 0
|
|
self.current_search_index = 0
|
|
|
|
# Track search attempts per track (max 5 attempts per song)
|
|
self.track_attempts = {} # track_index -> attempt_count
|
|
self.MAX_ATTEMPTS_PER_TRACK = 5
|
|
|
|
# CHANGED: Now using parallel system instead of sequential
|
|
print("🚀 Redirecting to parallel download system for better performance...")
|
|
# The parallel system is already started from start_soulseek_downloads()
|
|
# No need to call it again here
|
|
|
|
def start_next_track_search(self):
|
|
"""Start searching for the next track in sequence"""
|
|
# Check for cancellation
|
|
if hasattr(self, 'cancel_requested') and self.cancel_requested:
|
|
print("🛑 Search cancelled by user")
|
|
return
|
|
|
|
if self.current_search_index >= len(self.missing_tracks):
|
|
# All searches complete - check if we should finish
|
|
if self.completed_downloads >= len(self.missing_tracks):
|
|
self.on_all_downloads_complete()
|
|
return
|
|
|
|
track_result = self.missing_tracks[self.current_search_index]
|
|
self.start_single_track_download(track_result, self.current_search_index)
|
|
|
|
def start_single_track_download(self, track_result, track_index):
|
|
"""Start download for a single track using smart search strategy"""
|
|
track = track_result.spotify_track
|
|
artist_name = track.artists[0] if track.artists else "Unknown Artist"
|
|
track_name = track.name
|
|
|
|
print(f"🎵 Starting search {track_index + 1}/{len(self.missing_tracks)}: {track_name} by {artist_name}")
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
progress_pct = ((track_index + 1) / len(self.missing_tracks)) * 100
|
|
self.parent_page.log_area.append(f"🔍 Searching ({track_index + 1}/{len(self.missing_tracks)}, {progress_pct:.0f}%): {track_name} by {artist_name}")
|
|
|
|
# Update table to show searching status
|
|
table_index = self.find_track_index(track)
|
|
if table_index is not None:
|
|
searching_item = QTableWidgetItem("🔍 Searching")
|
|
searching_item.setFlags(searching_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
searching_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, searching_item)
|
|
|
|
# Use smart search strategy to find best match
|
|
self.smart_search_and_download(track, track_index, table_index)
|
|
|
|
def smart_search_and_download(self, spotify_track, track_index, table_index):
|
|
"""Implement smart search strategy with multiple query variations"""
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
|
track_name = spotify_track.name
|
|
duration_ms = getattr(spotify_track, 'duration_ms', 0)
|
|
|
|
# Generate multiple search queries in order of preference
|
|
search_queries = self.generate_smart_search_queries(artist_name, track_name)
|
|
|
|
print(f"🔍 Generated {len(search_queries)} search queries for: {track_name} by {artist_name}")
|
|
|
|
# Try each search query until we find good results
|
|
self.try_search_queries(search_queries, spotify_track, track_index, table_index, 0)
|
|
|
|
def clean_track_name_for_search(self, track_name):
|
|
"""Clean track name by removing parentheses content and extra information"""
|
|
import re
|
|
|
|
cleaned_name = track_name
|
|
|
|
# Remove content in parentheses - covers feat., remix, remaster, etc.
|
|
# Examples: "(feat. Artist)", "(Remix)", "(Remastered)", "(Live)", "(Radio Edit)"
|
|
cleaned_name = re.sub(r'\s*\([^)]*\)', '', cleaned_name)
|
|
|
|
# Remove content in square brackets - covers similar extras
|
|
# Examples: "[feat. Artist]", "[Remix]", "[Explicit]"
|
|
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name)
|
|
|
|
# Remove common standalone suffixes that appear after dashes
|
|
# Examples: " - Remix", " - Radio Edit", " - Extended Version"
|
|
suffixes_to_remove = [
|
|
r'\s*-\s*remix\s*$',
|
|
r'\s*-\s*radio\s+edit\s*$',
|
|
r'\s*-\s*extended\s+version\s*$',
|
|
r'\s*-\s*remaster\s*$',
|
|
r'\s*-\s*remastered\s*$',
|
|
r'\s*-\s*live\s*$',
|
|
r'\s*-\s*acoustic\s*$',
|
|
r'\s*-\s*instrumental\s*$'
|
|
]
|
|
|
|
for suffix_pattern in suffixes_to_remove:
|
|
cleaned_name = re.sub(suffix_pattern, '', cleaned_name, flags=re.IGNORECASE)
|
|
|
|
# Clean up extra whitespace
|
|
cleaned_name = ' '.join(cleaned_name.split())
|
|
|
|
# Log cleaning if significant changes were made
|
|
if cleaned_name != track_name:
|
|
print(f"🧹 Cleaned track name: '{track_name}' → '{cleaned_name}'")
|
|
|
|
return cleaned_name
|
|
|
|
def generate_smart_search_queries(self, artist_name, track_name):
|
|
"""Generate multiple search query variations with special handling for single-word tracks"""
|
|
# Clean the track name first to remove parentheses content
|
|
clean_track_name = self.clean_track_name_for_search(track_name)
|
|
|
|
queries = []
|
|
|
|
# Check if track name is a single word (no spaces)
|
|
is_single_word = len(clean_track_name.strip().split()) == 1
|
|
|
|
# NEW STRATEGY per user request:
|
|
# 1. "Virtual Mage Orbit Love" (Artist + Track)
|
|
# 2. "Orbit Love Virtual M" (Track + Shortened Artist)
|
|
# 3. "Orbit Love" (Track only)
|
|
|
|
# Strategy 1: Artist + Track (recommended format: "Virtual Mage Orbit Love")
|
|
if artist_name:
|
|
queries.append(f"{artist_name} {clean_track_name}".strip())
|
|
|
|
# Strategy 2: Track + Shortened Artist (fallback: "Orbit Love Virtual M")
|
|
if artist_name:
|
|
artist_words = artist_name.split()
|
|
if len(artist_words) > 1:
|
|
# For multi-word artists: Take first word + first letter of second word
|
|
short_artist = artist_words[0] + " " + artist_words[1][0] if len(artist_words[1]) > 0 else artist_words[0]
|
|
else:
|
|
# For single-word artists: Truncate to ~7 characters
|
|
short_artist = artist_words[0][:7] if len(artist_words[0]) > 7 else artist_words[0]
|
|
|
|
queries.append(f"{clean_track_name} {short_artist}".strip())
|
|
|
|
# Strategy 3: Track only (final fallback: "Orbit Love")
|
|
queries.append(clean_track_name.strip())
|
|
|
|
# Legacy single-word handling for backward compatibility
|
|
if is_single_word:
|
|
print(f"🎯 Single-word track detected: '{track_name}' - using enhanced search strategy")
|
|
first_word = artist_name.split()[0] if artist_name else ""
|
|
if first_word and len(first_word) > 2:
|
|
queries.append(f"{track_name} {first_word}".strip())
|
|
|
|
# Strategy 4: Track name + full artist name (traditional approach)
|
|
if artist_name:
|
|
queries.append(f"{track_name} {artist_name}".strip())
|
|
|
|
# Remove duplicates while preserving order
|
|
unique_queries = []
|
|
for query in queries:
|
|
if query and query not in unique_queries:
|
|
unique_queries.append(query)
|
|
|
|
return unique_queries
|
|
|
|
def shorten_artist_name(self, artist_name):
|
|
"""Remove common articles and prefixes that cause search issues"""
|
|
if not artist_name:
|
|
return artist_name
|
|
|
|
# Common prefixes that cause search problems
|
|
prefixes_to_remove = [
|
|
"The ", "A ", "An ",
|
|
"DJ ", "MC ", "Lil ", "Big ",
|
|
"Young ", "Old ", "Saint ", "St. "
|
|
]
|
|
|
|
shortened = artist_name
|
|
for prefix in prefixes_to_remove:
|
|
if shortened.startswith(prefix):
|
|
shortened = shortened[len(prefix):]
|
|
break
|
|
|
|
# Remove common suffixes
|
|
suffixes_to_remove = [" Jr.", " Sr.", " Jr", " Sr", " III", " II"]
|
|
for suffix in suffixes_to_remove:
|
|
if shortened.endswith(suffix):
|
|
shortened = shortened[:-len(suffix)]
|
|
break
|
|
|
|
return shortened.strip()
|
|
|
|
def generate_word_removal_fallbacks(self, original_query, spotify_track):
|
|
"""Generate fallback queries by removing words when original query returns 0 results"""
|
|
fallback_queries = []
|
|
|
|
# Parse the original query to understand its components
|
|
track_name = spotify_track.name.strip()
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
|
|
print(f"🔄 Generating fallbacks for: '{original_query}' (track: '{track_name}', artist: '{artist_name}')")
|
|
|
|
# If the query contains the artist name, try removing words from artist
|
|
if artist_name and artist_name.lower() in original_query.lower():
|
|
# Remove common words from artist name
|
|
artist_words = artist_name.split()
|
|
if len(artist_words) > 1:
|
|
# Try removing first word if it's a common article/prefix
|
|
if artist_words[0].lower() in ['the', 'a', 'an', 'dj', 'mc', 'lil', 'big', 'young', 'old', 'saint', 'st.', 'dr.', 'mr.', 'ms.']:
|
|
reduced_artist = ' '.join(artist_words[1:])
|
|
fallback_query = f"{track_name} {reduced_artist}".strip()
|
|
if fallback_query != original_query and fallback_query not in fallback_queries:
|
|
fallback_queries.append(fallback_query)
|
|
print(f" 💡 Fallback 1: '{fallback_query}' (removed article/prefix)")
|
|
|
|
# Try removing last word (often suffixes like Jr., Sr., etc.)
|
|
if artist_words[-1].lower() in ['jr.', 'sr.', 'jr', 'sr', 'iii', 'ii', 'iv', 'v']:
|
|
reduced_artist = ' '.join(artist_words[:-1])
|
|
fallback_query = f"{track_name} {reduced_artist}".strip()
|
|
if fallback_query != original_query and fallback_query not in fallback_queries:
|
|
fallback_queries.append(fallback_query)
|
|
print(f" 💡 Fallback 2: '{fallback_query}' (removed suffix)")
|
|
|
|
# Try just first word of artist if multi-word
|
|
if len(artist_words) >= 2:
|
|
first_word = artist_words[0]
|
|
if len(first_word) > 2 and first_word.lower() not in ['the', 'a', 'an']:
|
|
fallback_query = f"{track_name} {first_word}".strip()
|
|
if fallback_query != original_query and fallback_query not in fallback_queries:
|
|
fallback_queries.append(fallback_query)
|
|
print(f" 💡 Fallback 3: '{fallback_query}' (first word only)")
|
|
|
|
# Try just last word of artist if it's likely the main name
|
|
if len(artist_words) >= 2:
|
|
last_word = artist_words[-1]
|
|
if len(last_word) > 2 and last_word.lower() not in ['jr.', 'sr.', 'jr', 'sr', 'iii', 'ii']:
|
|
fallback_query = f"{track_name} {last_word}".strip()
|
|
if fallback_query != original_query and fallback_query not in fallback_queries:
|
|
fallback_queries.append(fallback_query)
|
|
print(f" 💡 Fallback 4: '{fallback_query}' (last word only)")
|
|
|
|
# Try just track name if not already tried
|
|
if track_name.strip() != original_query and track_name.strip() not in fallback_queries:
|
|
fallback_queries.append(track_name.strip())
|
|
print(f" 💡 Fallback 5: '{track_name.strip()}' (track only)")
|
|
|
|
# Try removing special characters and punctuation from track name
|
|
import re
|
|
clean_track = re.sub(r'[^\w\s]', '', track_name).strip()
|
|
if clean_track != track_name and clean_track not in fallback_queries:
|
|
fallback_queries.append(clean_track)
|
|
print(f" 💡 Fallback 6: '{clean_track}' (cleaned track)")
|
|
|
|
# If track has multiple words, try just the first word
|
|
track_words = track_name.split()
|
|
if len(track_words) > 1:
|
|
first_track_word = track_words[0]
|
|
if len(first_track_word) > 2 and first_track_word not in fallback_queries:
|
|
fallback_queries.append(first_track_word)
|
|
print(f" 💡 Fallback 7: '{first_track_word}' (first track word)")
|
|
|
|
print(f"🔄 Generated {len(fallback_queries)} fallback queries")
|
|
return fallback_queries
|
|
|
|
def generate_and_try_fallback_queries(self, spotify_track, track_index, table_index):
|
|
"""Generate and try fallback queries when initial queries fail"""
|
|
print(f"🔄 Generating fallback queries for track {track_index + 1} (attempt {self.track_attempts[track_index]}/{self.MAX_ATTEMPTS_PER_TRACK})")
|
|
|
|
# Generate fallback queries using existing logic
|
|
original_query = spotify_track.name # Start with track name for fallbacks
|
|
fallback_queries = self.generate_word_removal_fallbacks(original_query, spotify_track)
|
|
|
|
if fallback_queries:
|
|
print(f"🔄 Trying {len(fallback_queries)} fallback queries...")
|
|
# Start trying the fallback queries
|
|
self.try_search_queries(fallback_queries, spotify_track, track_index, table_index, 0)
|
|
else:
|
|
print(f"❌ No fallback queries generated - marking track as failed")
|
|
self.on_search_failed(spotify_track, track_index, table_index)
|
|
|
|
def try_search_queries(self, queries, spotify_track, track_index, table_index, query_index):
|
|
"""Try search queries sequentially until we find good results"""
|
|
# Check for cancellation
|
|
if hasattr(self, 'cancel_requested') and self.cancel_requested:
|
|
print("🛑 Search queries cancelled by user")
|
|
return
|
|
|
|
# Check max attempts limit (5 total queries per track)
|
|
if not hasattr(self, 'track_attempts'):
|
|
self.track_attempts = {}
|
|
|
|
current_attempts = self.track_attempts.get(track_index, 0)
|
|
if current_attempts >= self.MAX_ATTEMPTS_PER_TRACK:
|
|
print(f"⚠️ Max attempts ({self.MAX_ATTEMPTS_PER_TRACK}) reached for track {track_index + 1}")
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
track_name = spotify_track.name
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
|
self.parent_page.log_area.append(f" ⚠️ Max attempts reached: {track_name} by {artist_name}")
|
|
self.on_search_failed(spotify_track, track_index, table_index)
|
|
return
|
|
|
|
if query_index >= len(queries):
|
|
# All queries in this batch failed - increment attempts
|
|
self.track_attempts[track_index] = current_attempts + 1
|
|
print(f"🔄 Attempt {self.track_attempts[track_index]}/{self.MAX_ATTEMPTS_PER_TRACK} failed for track {track_index + 1}")
|
|
|
|
# Try generating fallback queries for next attempt
|
|
if self.track_attempts[track_index] < self.MAX_ATTEMPTS_PER_TRACK:
|
|
self.generate_and_try_fallback_queries(spotify_track, track_index, table_index)
|
|
else:
|
|
self.on_search_failed(spotify_track, track_index, table_index)
|
|
return
|
|
|
|
current_query = queries[query_index]
|
|
attempts_info = f"(Attempt {current_attempts + 1}/{self.MAX_ATTEMPTS_PER_TRACK})"
|
|
print(f"🔍 Trying search query {query_index + 1}/{len(queries)} {attempts_info}: '{current_query}'")
|
|
|
|
# Update console with current search attempt
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f" 🔍 Query {query_index + 1} {attempts_info}: '{current_query}'")
|
|
|
|
# Start search using downloads.py SearchThread
|
|
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
|
|
|
|
class SmartSearchWorkerSignals(QObject):
|
|
search_completed = pyqtSignal(list, int, str) # results, query_index, query
|
|
search_failed = pyqtSignal(int, str, str) # query_index, query, error
|
|
|
|
class SmartSearchWorker(QRunnable):
|
|
def __init__(self, soulseek_client, query, query_index):
|
|
super().__init__()
|
|
self.soulseek_client = soulseek_client
|
|
self.query = query
|
|
self.query_index = query_index
|
|
self.signals = SmartSearchWorkerSignals()
|
|
self._stop_requested = False
|
|
|
|
def run(self):
|
|
loop = None
|
|
try:
|
|
import asyncio
|
|
# Create a completely fresh event loop for this thread
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
# Perform search with proper await
|
|
results = loop.run_until_complete(self._do_search())
|
|
|
|
if not self._stop_requested:
|
|
# Process results into the format expected by our system
|
|
processed_results = self._process_search_results(results)
|
|
self.signals.search_completed.emit(processed_results, self.query_index, self.query)
|
|
|
|
except Exception as e:
|
|
if not self._stop_requested:
|
|
self.signals.search_failed.emit(self.query_index, self.query, str(e))
|
|
finally:
|
|
# Ensure proper cleanup
|
|
if loop:
|
|
try:
|
|
# Close any remaining tasks
|
|
pending = asyncio.all_tasks(loop)
|
|
for task in pending:
|
|
task.cancel()
|
|
|
|
if pending:
|
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
|
|
loop.close()
|
|
except Exception as e:
|
|
print(f"Error cleaning up event loop: {e}")
|
|
|
|
async def _do_search(self):
|
|
"""Perform the actual search with proper await"""
|
|
# Check for cancellation before starting search
|
|
if self._stop_requested:
|
|
print(f"🛑 Search cancelled before starting: {self.query}")
|
|
return []
|
|
|
|
return await self.soulseek_client.search(self.query)
|
|
|
|
def _process_search_results(self, raw_results):
|
|
"""Process raw search results into the format we need"""
|
|
if not raw_results:
|
|
return []
|
|
|
|
# The search returns a tuple (tracks, albums)
|
|
processed_results = []
|
|
|
|
if isinstance(raw_results, tuple) and len(raw_results) == 2:
|
|
tracks, albums = raw_results
|
|
|
|
# Add individual tracks
|
|
if tracks:
|
|
processed_results.extend(tracks)
|
|
|
|
# Add tracks from albums (since we're looking for individual tracks)
|
|
if albums:
|
|
for album in albums:
|
|
if hasattr(album, 'tracks') and album.tracks:
|
|
processed_results.extend(album.tracks)
|
|
|
|
print(f"🔍 Processed {len(processed_results)} track results from search")
|
|
return processed_results
|
|
|
|
# Create and start search worker
|
|
worker = SmartSearchWorker(self.parent_page.soulseek_client, current_query, query_index)
|
|
worker.signals.search_completed.connect(
|
|
lambda results, qi, query: self.on_search_query_completed(
|
|
results, queries, spotify_track, track_index, table_index, qi, query
|
|
)
|
|
)
|
|
worker.signals.search_failed.connect(
|
|
lambda qi, query, error: self.on_search_query_failed(
|
|
queries, spotify_track, track_index, table_index, qi, query, error
|
|
)
|
|
)
|
|
|
|
# CRITICAL: Track worker for cancellation
|
|
if not hasattr(self, 'active_workers'):
|
|
self.active_workers = []
|
|
self.active_workers.append(worker)
|
|
|
|
# Submit to thread pool
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
thread_pool = QThreadPool()
|
|
self.fallback_pools.append(thread_pool)
|
|
thread_pool.start(worker)
|
|
|
|
def on_search_query_completed(self, results, queries, spotify_track, track_index, table_index, query_index, query):
|
|
"""Handle completion of a search query"""
|
|
# Check for cancellation
|
|
if hasattr(self, 'cancel_requested') and self.cancel_requested:
|
|
print("🛑 Search query completed after cancellation - ignoring results")
|
|
return
|
|
|
|
print(f"✅ Search query '{query}' returned {len(results)} results")
|
|
|
|
# Update console with result count
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f" ✅ Found {len(results)} tracks for '{query}'")
|
|
|
|
if results and len(results) > 0:
|
|
# Found results - collect all valid candidates (not just best match)
|
|
valid_candidates = self.get_valid_candidates(results, spotify_track, query)
|
|
|
|
if valid_candidates:
|
|
print(f"🎯 Found {len(valid_candidates)} valid candidates")
|
|
|
|
# Store all candidates for potential retry
|
|
if not hasattr(self, 'track_search_results'):
|
|
self.track_search_results = {}
|
|
self.track_search_results[track_index] = valid_candidates
|
|
|
|
# Reset candidate index for this track
|
|
if not hasattr(self, 'track_candidate_index'):
|
|
self.track_candidate_index = {}
|
|
self.track_candidate_index[track_index] = 0
|
|
|
|
# Start with the best candidate
|
|
best_match = valid_candidates[0]
|
|
print(f"🎯 Selected best match (1/{len(valid_candidates)}): {best_match.filename}")
|
|
|
|
# Update console with selection
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f" 🎯 Best match (1/{len(valid_candidates)}): {best_match.filename}")
|
|
|
|
# NEW: Start with pre-download Spotify validation instead of direct download
|
|
print(f"🔍 Starting pre-download validation for: {best_match.filename}")
|
|
self.validate_slskd_result_with_spotify(best_match, spotify_track, track_index, table_index, valid_candidates)
|
|
return
|
|
|
|
# Check if this query returned zero results and try word removal fallback
|
|
if len(results) == 0:
|
|
print(f"⚠️ Query '{query}' returned 0 results, trying word removal fallback...")
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f" ⚠️ 0 results for '{query}', trying word removal...")
|
|
|
|
# Generate fallback queries with word removal
|
|
fallback_queries = self.generate_word_removal_fallbacks(query, spotify_track)
|
|
if fallback_queries:
|
|
print(f"🔄 Generated {len(fallback_queries)} fallback queries")
|
|
# Insert fallback queries into the remaining query list
|
|
remaining_queries = queries[query_index + 1:]
|
|
new_queries = queries[:query_index + 1] + fallback_queries + remaining_queries
|
|
# Try the first fallback
|
|
self.try_search_queries(new_queries, spotify_track, track_index, table_index, query_index + 1)
|
|
return
|
|
|
|
# No good results, try next query
|
|
print(f"⚠️ Query '{query}' had no suitable matches, trying next...")
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f" ⚠️ No suitable matches for '{query}', trying next strategy...")
|
|
self.try_search_queries(queries, spotify_track, track_index, table_index, query_index + 1)
|
|
|
|
def on_search_query_failed(self, queries, spotify_track, track_index, table_index, query_index, query, error):
|
|
"""Handle search query failure"""
|
|
# Check for cancellation
|
|
if hasattr(self, 'cancel_requested') and self.cancel_requested:
|
|
print("🛑 Search query failed after cancellation - stopping retry")
|
|
return
|
|
|
|
print(f"❌ Search query '{query}' failed: {error}")
|
|
|
|
# Try next query
|
|
self.try_search_queries(queries, spotify_track, track_index, table_index, query_index + 1)
|
|
|
|
def select_best_match(self, results, spotify_track, query):
|
|
"""Select the best match from search results using strict matching criteria"""
|
|
if not results:
|
|
return None
|
|
|
|
# Get Spotify track metadata for comparison
|
|
track_name = spotify_track.name.lower().strip()
|
|
artist_name = spotify_track.artists[0].lower().strip() if spotify_track.artists else ""
|
|
duration_ms = getattr(spotify_track, 'duration_ms', 0)
|
|
target_duration_seconds = duration_ms / 1000 if duration_ms > 0 else None
|
|
|
|
print(f"🎯 Matching against: '{track_name}' by '{artist_name}' ({target_duration_seconds}s)")
|
|
|
|
# Score each result
|
|
scored_results = []
|
|
|
|
for result in results:
|
|
score = 0
|
|
reasons = [] # For debugging
|
|
|
|
# Get result metadata with proper null handling
|
|
result_title = ""
|
|
if hasattr(result, 'title') and result.title:
|
|
result_title = str(result.title).lower().strip()
|
|
|
|
result_artist = ""
|
|
if hasattr(result, 'artist') and result.artist:
|
|
result_artist = str(result.artist).lower().strip()
|
|
|
|
result_filename = ""
|
|
if hasattr(result, 'filename') and result.filename:
|
|
result_filename = str(result.filename).lower()
|
|
|
|
result_duration = 0
|
|
if hasattr(result, 'duration') and result.duration:
|
|
result_duration = result.duration
|
|
|
|
# INTELLIGENT TRACK MATCHING: Use advanced matching with confidence scoring
|
|
match_result = self.intelligent_track_match(track_name, artist_name, result_title, result_artist, result_filename)
|
|
|
|
# Only proceed if we have a reasonable match
|
|
if not match_result['matched'] or match_result['confidence'] < 60:
|
|
continue # Skip this result entirely
|
|
|
|
# Score based on match confidence and type
|
|
base_score = match_result['confidence']
|
|
match_type = match_result['type']
|
|
|
|
# NEW STRICT MATCHING TYPES (user requirements)
|
|
if match_type == 'exact_title_artist':
|
|
score += 200 # HIGHEST priority for exact title + artist match (user requirement)
|
|
reasons.append(f"STRICT_title_artist_match({match_result['confidence']}%)")
|
|
elif match_type == 'exact_filename_artist':
|
|
score += 190 # Very high for exact filename + artist match
|
|
reasons.append(f"STRICT_filename_artist_match({match_result['confidence']}%)")
|
|
elif match_type == 'title_with_partial_artist':
|
|
score += 180 # High for title + partial artist
|
|
reasons.append(f"STRICT_title_partial_artist({match_result['confidence']}%)")
|
|
elif match_type == 'title_with_weak_artist':
|
|
score += 160 # Medium for title + weak artist
|
|
reasons.append(f"STRICT_title_weak_artist({match_result['confidence']}%)")
|
|
# LEGACY MATCH TYPES (for backwards compatibility)
|
|
elif match_type == 'exact_title':
|
|
score += 150 # Legacy: exact title match
|
|
reasons.append(f"track_exact_title({match_result['confidence']}%)")
|
|
elif match_type == 'exact_filename':
|
|
score += 140 # Legacy: exact filename match
|
|
reasons.append(f"track_exact_filename({match_result['confidence']}%)")
|
|
elif match_type == 'substring_title':
|
|
score += 130 # Legacy: substring in title
|
|
reasons.append(f"track_substring_title({match_result['confidence']}%)")
|
|
elif match_type == 'substring_filename':
|
|
score += 120 # Legacy: substring in filename
|
|
reasons.append(f"track_substring_filename({match_result['confidence']}%)")
|
|
elif match_type == 'word_match_high':
|
|
score += 110 # Legacy: high word match
|
|
reasons.append(f"track_word_match_high({match_result['confidence']}%)")
|
|
elif match_type == 'word_match_medium':
|
|
score += 100 # Legacy: medium word match
|
|
reasons.append(f"track_word_match_medium({match_result['confidence']}%)")
|
|
elif match_type == 'fuzzy_match':
|
|
score += 90 # Legacy: fuzzy match
|
|
reasons.append(f"track_fuzzy_match({match_result['confidence']}%)")
|
|
else:
|
|
score += base_score # Use confidence as base score
|
|
reasons.append(f"track_match_{match_type}({match_result['confidence']}%)")
|
|
|
|
# BONUS: Artist name contained (extra points)
|
|
artist_contained = False
|
|
|
|
# Check if artist name is contained in the result artist field
|
|
if artist_name and artist_name in result_artist:
|
|
score += 80
|
|
reasons.append("artist_exact_in_artist")
|
|
artist_contained = True
|
|
|
|
# Check if artist name is contained in the filename
|
|
elif artist_name and artist_name in result_filename:
|
|
score += 60
|
|
reasons.append("artist_exact_in_filename")
|
|
artist_contained = True
|
|
|
|
# Check if artist name is contained in the title
|
|
elif artist_name and artist_name in result_title:
|
|
score += 40
|
|
reasons.append("artist_exact_in_title")
|
|
artist_contained = True
|
|
|
|
# CRITICAL: Duration matching (highest priority for accuracy)
|
|
if target_duration_seconds and result_duration > 0:
|
|
duration_diff = abs(result_duration - target_duration_seconds)
|
|
if duration_diff <= 2: # Within 2 seconds - almost certainly correct
|
|
score += 100
|
|
reasons.append(f"duration_perfect({duration_diff:.1f}s)")
|
|
elif duration_diff <= 5: # Within 5 seconds - very likely correct
|
|
score += 60
|
|
reasons.append(f"duration_very_good({duration_diff:.1f}s)")
|
|
elif duration_diff <= 10: # Within 10 seconds - likely correct
|
|
score += 30
|
|
reasons.append(f"duration_good({duration_diff:.1f}s)")
|
|
elif duration_diff <= 30: # Within 30 seconds - possibly correct
|
|
score += 10
|
|
reasons.append(f"duration_fair({duration_diff:.1f}s)")
|
|
else:
|
|
# Penalty for very different duration
|
|
score -= 20
|
|
reasons.append(f"duration_mismatch({duration_diff:.1f}s)")
|
|
|
|
# ENHANCED QUALITY PREFERENCE: Heavily prioritize FLAC and high quality
|
|
quality_score = self.calculate_quality_score(result, result_filename)
|
|
score += quality_score['score']
|
|
if quality_score['reason']:
|
|
reasons.append(quality_score['reason'])
|
|
|
|
# File size reasonableness (avoid tiny or corrupted files)
|
|
if hasattr(result, 'size') and result.size:
|
|
if result.size > 5000000: # > 5MB (reasonable for music)
|
|
score += 5
|
|
reasons.append("size_reasonable")
|
|
elif result.size < 1000000: # < 1MB (suspicious)
|
|
score -= 10
|
|
reasons.append("size_suspicious")
|
|
|
|
# Penalize obvious mismatches in filename
|
|
if 'remix' in result_filename and 'remix' not in track_name.lower():
|
|
score -= 30
|
|
reasons.append("unwanted_remix")
|
|
|
|
if 'live' in result_filename and 'live' not in track_name.lower():
|
|
score -= 20
|
|
reasons.append("unwanted_live")
|
|
|
|
if 'instrumental' in result_filename and 'instrumental' not in track_name.lower():
|
|
score -= 25
|
|
reasons.append("unwanted_instrumental")
|
|
|
|
scored_results.append((score, result, reasons))
|
|
|
|
# Sort by score (highest first)
|
|
scored_results.sort(key=lambda x: x[0], reverse=True)
|
|
|
|
if scored_results:
|
|
best_score, best_result, best_reasons = scored_results[0]
|
|
print(f"🏆 Best match score: {best_score} - {' + '.join(best_reasons)}")
|
|
print(f" File: {best_result.filename}")
|
|
|
|
# Only return result if score is reasonable (avoid terrible matches)
|
|
# Since we now require exact track name containment (120-150 points minimum),
|
|
# we can set a higher threshold
|
|
if best_score >= 120: # Require minimum quality with strict matching
|
|
return best_result
|
|
else:
|
|
print(f"⚠️ Best score ({best_score}) too low, rejecting all matches")
|
|
return None
|
|
|
|
return None
|
|
|
|
def get_valid_candidates(self, results, spotify_track, query):
|
|
"""Get all valid candidates sorted by score (for retry mechanism)"""
|
|
if not results:
|
|
return []
|
|
|
|
# Get Spotify track metadata for comparison
|
|
track_name = spotify_track.name.lower().strip()
|
|
artist_name = spotify_track.artists[0].lower().strip() if spotify_track.artists else ""
|
|
duration_ms = getattr(spotify_track, 'duration_ms', 0)
|
|
target_duration_seconds = duration_ms / 1000 if duration_ms > 0 else None
|
|
|
|
# Score each result
|
|
scored_results = []
|
|
|
|
for result in results:
|
|
score = 0
|
|
reasons = []
|
|
|
|
# Get result metadata with proper null handling
|
|
result_title = ""
|
|
if hasattr(result, 'title') and result.title:
|
|
result_title = str(result.title).lower().strip()
|
|
|
|
result_artist = ""
|
|
if hasattr(result, 'artist') and result.artist:
|
|
result_artist = str(result.artist).lower().strip()
|
|
|
|
result_filename = ""
|
|
if hasattr(result, 'filename') and result.filename:
|
|
result_filename = str(result.filename).lower()
|
|
|
|
result_duration = 0
|
|
if hasattr(result, 'duration') and result.duration:
|
|
result_duration = result.duration
|
|
|
|
# Use the same intelligent matching as select_best_match
|
|
match_result = self.intelligent_track_match(track_name, artist_name, result_title, result_artist, result_filename)
|
|
|
|
# Only include candidates that meet minimum match criteria
|
|
if not match_result['matched'] or match_result['confidence'] < 60:
|
|
continue # Skip this result
|
|
|
|
# Calculate full score using the same logic as select_best_match
|
|
score = self._calculate_candidate_score(match_result, artist_name, result_artist, result_filename,
|
|
target_duration_seconds, result_duration, result, reasons)
|
|
|
|
if score >= 120: # Same minimum threshold as select_best_match
|
|
scored_results.append((score, result, reasons))
|
|
|
|
# Sort by score (highest first)
|
|
scored_results.sort(key=lambda x: x[0], reverse=True)
|
|
|
|
# Return just the result objects, sorted by quality
|
|
candidates = [result for score, result, reasons in scored_results]
|
|
|
|
print(f"🔍 Valid candidates found: {len(candidates)} (from {len(results)} total results)")
|
|
for i, (score, result, reasons) in enumerate(scored_results[:5]): # Show top 5
|
|
print(f" {i+1}. Score: {score} - {result.filename} - {' + '.join(reasons[:3])}")
|
|
|
|
return candidates
|
|
|
|
def _calculate_candidate_score(self, match_result, artist_name, result_artist, result_filename,
|
|
target_duration_seconds, result_duration, result, reasons):
|
|
"""Calculate full score for a candidate (extracted from select_best_match logic)"""
|
|
score = 0
|
|
|
|
# Track matching score
|
|
match_type = match_result['type']
|
|
if match_type == 'exact_title':
|
|
score += 150
|
|
reasons.append(f"track_exact_title({match_result['confidence']}%)")
|
|
elif match_type == 'exact_filename':
|
|
score += 140
|
|
reasons.append(f"track_exact_filename({match_result['confidence']}%)")
|
|
elif match_type == 'substring_title':
|
|
score += 130
|
|
reasons.append(f"track_substring_title({match_result['confidence']}%)")
|
|
elif match_type == 'substring_filename':
|
|
score += 120
|
|
reasons.append(f"track_substring_filename({match_result['confidence']}%)")
|
|
elif match_type == 'word_match_high':
|
|
score += 110
|
|
reasons.append(f"track_word_match_high({match_result['confidence']}%)")
|
|
elif match_type == 'word_match_medium':
|
|
score += 100
|
|
reasons.append(f"track_word_match_medium({match_result['confidence']}%)")
|
|
elif match_type == 'fuzzy_match':
|
|
score += 90
|
|
reasons.append(f"track_fuzzy_match({match_result['confidence']}%)")
|
|
|
|
# Artist matching
|
|
if artist_name and artist_name in result_artist:
|
|
score += 80
|
|
reasons.append("artist_exact_in_artist")
|
|
elif artist_name and artist_name in result_filename:
|
|
score += 60
|
|
reasons.append("artist_exact_in_filename")
|
|
|
|
# Duration matching
|
|
if target_duration_seconds and result_duration > 0:
|
|
duration_diff = abs(result_duration - target_duration_seconds)
|
|
if duration_diff <= 2:
|
|
score += 100
|
|
reasons.append(f"duration_perfect({duration_diff:.1f}s)")
|
|
elif duration_diff <= 5:
|
|
score += 60
|
|
reasons.append(f"duration_very_good({duration_diff:.1f}s)")
|
|
elif duration_diff <= 10:
|
|
score += 30
|
|
reasons.append(f"duration_good({duration_diff:.1f}s)")
|
|
|
|
# Quality scoring
|
|
quality_score = self.calculate_quality_score(result, result_filename)
|
|
score += quality_score['score']
|
|
if quality_score['reason']:
|
|
reasons.append(quality_score['reason'])
|
|
|
|
return score
|
|
|
|
def intelligent_track_match(self, spotify_track_name, spotify_artist_name, result_title, result_artist, result_filename):
|
|
"""
|
|
STRICT track matching as required by user:
|
|
1. Spotify playlist track title MUST be contained in slskd result filename/title
|
|
2. Secondary validation that extracted artist matches Spotify artist
|
|
|
|
Example:
|
|
- Spotify: "Orbit Love" by "Virtual Mage"
|
|
- Slskd result: "Virtual Mage - Orbit Love - 44hz.flac" ✅ MATCH (title + artist match)
|
|
- Slskd result: "Finley Quaye & William Orbit - Dice" ❌ NO MATCH (wrong track title)
|
|
"""
|
|
|
|
# Normalize inputs
|
|
spotify_title_norm = spotify_track_name.lower().strip()
|
|
spotify_artist_norm = spotify_artist_name.lower().strip()
|
|
result_title_norm = result_title.lower().strip() if result_title else ""
|
|
result_artist_norm = result_artist.lower().strip() if result_artist else ""
|
|
result_filename_norm = result_filename.lower().strip() if result_filename else ""
|
|
|
|
print(f"🔍 STRICT MATCH CHECK:")
|
|
print(f" 📋 Spotify: '{spotify_title_norm}' by '{spotify_artist_norm}'")
|
|
print(f" 📄 Result title: '{result_title_norm}' by '{result_artist_norm}'")
|
|
print(f" 📁 Result filename: '{result_filename_norm}'")
|
|
|
|
# REQUIREMENT 1: Spotify track title MUST be contained EXACTLY as consecutive words in result
|
|
# This prevents "Astral Chill" from matching "Astral Beach is Chill"
|
|
import re
|
|
|
|
# Create regex pattern for exact phrase matching (words must appear together in order)
|
|
# Escape special regex characters in the track name
|
|
escaped_title = re.escape(spotify_title_norm)
|
|
# Replace spaces in the escaped title with flexible whitespace/separator pattern
|
|
flexible_title_pattern = escaped_title.replace(r'\ ', r'[\s\-_\.]+')
|
|
# Add word boundaries to ensure we match complete words
|
|
exact_phrase_pattern = r'\b' + flexible_title_pattern + r'\b'
|
|
|
|
title_in_result_title = bool(re.search(exact_phrase_pattern, result_title_norm, re.IGNORECASE)) if result_title_norm else False
|
|
title_in_result_filename = bool(re.search(exact_phrase_pattern, result_filename_norm, re.IGNORECASE)) if result_filename_norm else False
|
|
|
|
if not title_in_result_title and not title_in_result_filename:
|
|
print(f" ❌ STRICT FAIL: Spotify track '{spotify_title_norm}' NOT found in result")
|
|
return {
|
|
'matched': False,
|
|
'confidence': 0,
|
|
'type': 'no_title_match',
|
|
'reason': f"Spotify track '{spotify_track_name}' not found in result filename/title"
|
|
}
|
|
|
|
print(f" ✅ TITLE MATCH: Spotify track found in result")
|
|
|
|
# REQUIREMENT 2: Secondary artist validation
|
|
artist_match_score = 0
|
|
artist_match_reason = ""
|
|
|
|
# Check if Spotify artist is in result artist field
|
|
if result_artist_norm and spotify_artist_norm in result_artist_norm:
|
|
artist_match_score = 100
|
|
artist_match_reason = f"exact artist match in result.artist"
|
|
print(f" ✅ ARTIST MATCH: '{spotify_artist_norm}' found in result artist '{result_artist_norm}'")
|
|
|
|
# Check if Spotify artist is in result filename
|
|
elif spotify_artist_norm in result_filename_norm:
|
|
artist_match_score = 90
|
|
artist_match_reason = f"exact artist match in filename"
|
|
print(f" ✅ ARTIST MATCH: '{spotify_artist_norm}' found in filename")
|
|
|
|
# Check if Spotify artist is in result title
|
|
elif result_title_norm and spotify_artist_norm in result_title_norm:
|
|
artist_match_score = 85
|
|
artist_match_reason = f"exact artist match in result.title"
|
|
print(f" ✅ ARTIST MATCH: '{spotify_artist_norm}' found in result title")
|
|
|
|
# Check for partial artist word matches (more lenient)
|
|
else:
|
|
spotify_artist_words = set(spotify_artist_norm.split())
|
|
result_text = f"{result_artist_norm} {result_filename_norm} {result_title_norm}"
|
|
result_words = set(result_text.split())
|
|
|
|
common_words = spotify_artist_words.intersection(result_words)
|
|
if common_words and len(common_words) >= len(spotify_artist_words) * 0.5: # At least 50% word match
|
|
artist_match_score = 70
|
|
artist_match_reason = f"partial artist word match: {common_words}"
|
|
print(f" ⚠️ PARTIAL ARTIST MATCH: {common_words} from '{spotify_artist_norm}'")
|
|
else:
|
|
artist_match_score = 0
|
|
artist_match_reason = f"no artist match found"
|
|
print(f" ❌ NO ARTIST MATCH: '{spotify_artist_norm}' not found in result")
|
|
|
|
# Calculate final confidence based on both title and artist matching
|
|
title_confidence = 100 if title_in_result_title and title_in_result_filename else 90
|
|
final_confidence = min(95, (title_confidence + artist_match_score) / 2)
|
|
|
|
# Determine match type based on strengths
|
|
if title_in_result_title and artist_match_score >= 85:
|
|
match_type = 'exact_title_artist'
|
|
elif title_in_result_filename and artist_match_score >= 85:
|
|
match_type = 'exact_filename_artist'
|
|
elif artist_match_score >= 70:
|
|
match_type = 'title_with_partial_artist'
|
|
elif artist_match_score > 0:
|
|
match_type = 'title_with_weak_artist'
|
|
else:
|
|
# Title matches but no artist match - this should be rejected as per user requirements
|
|
print(f" ❌ FINAL REJECT: Title matches but artist doesn't - likely wrong song!")
|
|
return {
|
|
'matched': False,
|
|
'confidence': 0,
|
|
'type': 'title_only_no_artist',
|
|
'reason': f"Title found but artist mismatch - prevents wrong song downloads"
|
|
}
|
|
|
|
print(f" ✅ FINAL MATCH: confidence={final_confidence:.0f}%, type={match_type}")
|
|
|
|
return {
|
|
'matched': True,
|
|
'confidence': int(final_confidence),
|
|
'type': match_type,
|
|
'reason': f"Title + {artist_match_reason}"
|
|
}
|
|
|
|
def calculate_string_similarity(self, str1, str2):
|
|
"""Calculate similarity between two strings (0.0 to 1.0)"""
|
|
if not str1 or not str2:
|
|
return 0.0
|
|
|
|
# Normalize strings
|
|
str1 = str1.lower().strip()
|
|
str2 = str2.lower().strip()
|
|
|
|
# Exact match
|
|
if str1 == str2:
|
|
return 1.0
|
|
|
|
# Simple similarity calculation
|
|
# Count matching words
|
|
words1 = set(str1.split())
|
|
words2 = set(str2.split())
|
|
|
|
if not words1 or not words2:
|
|
return 0.0
|
|
|
|
intersection = words1.intersection(words2)
|
|
union = words1.union(words2)
|
|
|
|
return len(intersection) / len(union) if union else 0.0
|
|
|
|
def calculate_quality_score(self, result, filename):
|
|
"""Calculate enhanced quality score prioritizing FLAC and high bitrates"""
|
|
score = 0
|
|
reason = ""
|
|
|
|
# Get file format from multiple sources
|
|
file_format = ""
|
|
bitrate = 0
|
|
|
|
# Check quality field first
|
|
if hasattr(result, 'quality') and result.quality:
|
|
quality_lower = result.quality.lower()
|
|
file_format = quality_lower
|
|
|
|
# Also check filename for format clues
|
|
filename_lower = filename.lower() if filename else ""
|
|
|
|
# Extract format from filename if not found in quality field
|
|
if not file_format:
|
|
if '.flac' in filename_lower:
|
|
file_format = 'flac'
|
|
elif '.alac' in filename_lower or '.m4a' in filename_lower:
|
|
file_format = 'alac'
|
|
elif '.ape' in filename_lower:
|
|
file_format = 'ape'
|
|
elif '.mp3' in filename_lower:
|
|
file_format = 'mp3'
|
|
elif '.aac' in filename_lower:
|
|
file_format = 'aac'
|
|
elif '.ogg' in filename_lower or '.oga' in filename_lower:
|
|
file_format = 'ogg'
|
|
|
|
# Extract bitrate from filename (common patterns)
|
|
import re
|
|
bitrate_match = re.search(r'(\d{2,4})\s*k?bps?', filename_lower)
|
|
if not bitrate_match:
|
|
bitrate_match = re.search(r'\[(\d{2,4})k?\]', filename_lower)
|
|
if not bitrate_match:
|
|
bitrate_match = re.search(r'(\d{2,4})k(?![a-z])', filename_lower) # 320k but not 320kb
|
|
|
|
if bitrate_match:
|
|
try:
|
|
bitrate = int(bitrate_match.group(1))
|
|
except:
|
|
bitrate = 0
|
|
|
|
# PRIORITY 1: FLAC gets highest bonus (user requirement)
|
|
if file_format == 'flac' or 'flac' in filename_lower:
|
|
score += 50 # Significantly higher than old +15
|
|
reason = f"format_flac_priority"
|
|
|
|
# Extra bonus for high quality FLAC indicators
|
|
if any(indicator in filename_lower for indicator in ['24bit', '24-bit', '96khz', '192khz', 'hi-res']):
|
|
score += 20
|
|
reason += "_hires"
|
|
|
|
# PRIORITY 2: Other lossless formats
|
|
elif file_format in ['alac', 'ape']:
|
|
score += 35
|
|
reason = f"format_lossless_{file_format}"
|
|
|
|
# PRIORITY 3: High bitrate MP3/AAC (320kbps)
|
|
elif file_format in ['mp3', 'aac']:
|
|
if bitrate >= 320:
|
|
score += 25
|
|
reason = f"format_mp3_320kbps"
|
|
elif bitrate >= 256:
|
|
score += 15
|
|
reason = f"format_mp3_256kbps"
|
|
elif bitrate >= 192:
|
|
score += 10
|
|
reason = f"format_mp3_192kbps"
|
|
elif bitrate >= 128:
|
|
score += 5
|
|
reason = f"format_mp3_128kbps"
|
|
else:
|
|
score += 5 # Unknown bitrate MP3
|
|
reason = f"format_mp3_unknown"
|
|
|
|
# PRIORITY 4: Other formats
|
|
elif file_format == 'ogg':
|
|
score += 8
|
|
reason = "format_ogg"
|
|
else:
|
|
# Unknown format - give minimal points
|
|
score += 2
|
|
reason = "format_unknown"
|
|
|
|
# BONUS: Clean filename (no brackets, underscores, or messy formatting)
|
|
clean_filename_score = 0
|
|
if filename_lower:
|
|
# Penalty for messy filenames
|
|
underscore_count = filename_lower.count('_')
|
|
if underscore_count > 3: # Too many underscores
|
|
clean_filename_score -= 5
|
|
elif '[' in filename_lower and ']' in filename_lower:
|
|
# Some brackets are OK (like [FLAC]) but too many is messy
|
|
bracket_count = filename_lower.count('[') + filename_lower.count(']')
|
|
if bracket_count > 4:
|
|
clean_filename_score -= 3
|
|
|
|
# Bonus for clean formatting
|
|
if not any(char in filename_lower for char in ['_', '@', '#', '$', '%']):
|
|
clean_filename_score += 10
|
|
if reason:
|
|
reason += "_clean"
|
|
|
|
score += clean_filename_score
|
|
|
|
# BONUS: Album context detection
|
|
if any(indicator in filename_lower for indicator in ['album', 'discography', 'collection']):
|
|
score += 5
|
|
if reason:
|
|
reason += "_album_context"
|
|
|
|
return {'score': score, 'reason': reason}
|
|
|
|
|
|
def normalize_track_title(self, title):
|
|
"""Normalize track title by removing common formatting and extra content"""
|
|
if not title:
|
|
return ""
|
|
|
|
import re
|
|
|
|
# Convert to lowercase and strip
|
|
normalized = title.lower().strip()
|
|
|
|
# Remove file extensions
|
|
normalized = re.sub(r'\.(flac|mp3|aac|alac|ape|ogg|m4a)$', '', normalized)
|
|
|
|
# Remove common bracketed content (but preserve essential parts)
|
|
# Remove quality indicators: [320kbps], [FLAC], [24bit], etc.
|
|
normalized = re.sub(r'\[(320|256|192|128)k?bps?\]', '', normalized)
|
|
normalized = re.sub(r'\[flac\]', '', normalized)
|
|
normalized = re.sub(r'\[24bit\]', '', normalized)
|
|
normalized = re.sub(r'\[hi-?res\]', '', normalized)
|
|
|
|
# Remove track numbers: "01. ", "1-", "01 - "
|
|
normalized = re.sub(r'^(\d{1,2}[-.\s]*)', '', normalized)
|
|
|
|
# Remove common separators between track and artist when they appear together
|
|
# "Track Name - Artist Name" -> focus on track name part
|
|
if ' - ' in normalized:
|
|
parts = normalized.split(' - ')
|
|
# Usually the first part is the track name
|
|
if len(parts) >= 2:
|
|
normalized = parts[0].strip()
|
|
|
|
# Remove featuring info: "(feat. Artist)", "ft. Artist", etc.
|
|
normalized = re.sub(r'\(feat\.?[^)]*\)', '', normalized)
|
|
normalized = re.sub(r'\bft\.?\s+[^,\s]+', '', normalized)
|
|
normalized = re.sub(r'\bfeat\.?\s+[^,\s]+', '', normalized)
|
|
|
|
# Remove common extra content
|
|
normalized = re.sub(r'\(remix\)', '', normalized)
|
|
normalized = re.sub(r'\(remaster\)', '', normalized)
|
|
normalized = re.sub(r'\(official[^)]*\)', '', normalized)
|
|
|
|
# Replace multiple separators with spaces
|
|
normalized = re.sub(r'[_\-\.\s]+', ' ', normalized)
|
|
|
|
# Remove extra whitespace
|
|
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
|
|
|
return normalized
|
|
|
|
def simplify_complex_title(self, text):
|
|
"""Simplify complex titles that may have artist names, quality info, etc."""
|
|
import re
|
|
|
|
# Remove everything in brackets and parentheses
|
|
simplified = re.sub(r'\[[^\]]*\]', '', text)
|
|
simplified = re.sub(r'\([^)]*\)', '', text)
|
|
|
|
# Remove common quality indicators
|
|
simplified = re.sub(r'\b(320|256|192|128)k?bps?\b', '', simplified)
|
|
simplified = re.sub(r'\bflac\b', '', simplified)
|
|
simplified = re.sub(r'\bmp3\b', '', simplified)
|
|
|
|
# Remove excessive punctuation
|
|
simplified = re.sub(r'[_\-\.\s]+', ' ', simplified)
|
|
simplified = re.sub(r'\s+', ' ', simplified).strip()
|
|
|
|
return simplified
|
|
|
|
def start_download_with_match(self, search_result, spotify_track, track_index, table_index):
|
|
"""Start download using the matched search result and downloads.py infrastructure"""
|
|
print(f"🚀 Starting download with matched result: {search_result.filename}")
|
|
|
|
# Update table to show downloading status
|
|
if table_index is not None:
|
|
downloading_item = QTableWidgetItem("⏬ Downloading")
|
|
downloading_item.setFlags(downloading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloading_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, downloading_item)
|
|
|
|
# Update console
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f"🎵 Downloading: {search_result.filename}")
|
|
|
|
# Use downloads.py infrastructure for the actual download with auto-matching
|
|
self.start_matched_download_via_infrastructure(search_result, track_index, table_index)
|
|
|
|
# Move to next track search
|
|
self.advance_to_next_track()
|
|
|
|
def on_search_failed(self, spotify_track, track_index, table_index):
|
|
"""Handle case where all search queries failed"""
|
|
track_name = spotify_track.name
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
|
|
|
# Get attempt count
|
|
attempts_made = self.track_attempts.get(track_index, 0)
|
|
print(f"❌ All search strategies failed for: {track_name} by {artist_name} after {attempts_made} attempts")
|
|
|
|
# Update console
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f"❌ Failed after {attempts_made} attempts: {track_name} by {artist_name}")
|
|
|
|
# Update table to show failed status
|
|
if table_index is not None:
|
|
failed_item = QTableWidgetItem("❌ No Results")
|
|
failed_item.setFlags(failed_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
failed_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, failed_item)
|
|
|
|
# Update completion tracking
|
|
self.completed_downloads += 1
|
|
self.failed_downloads += 1
|
|
self.download_progress.setValue(self.completed_downloads)
|
|
|
|
# Move to next track search
|
|
self.advance_to_next_track()
|
|
|
|
def advance_to_next_track(self):
|
|
"""Move to searching the next track"""
|
|
# Check for cancellation
|
|
if hasattr(self, 'cancel_requested') and self.cancel_requested:
|
|
print("🛑 Track advancement cancelled by user")
|
|
return
|
|
|
|
self.current_search_index += 1
|
|
self.start_next_track_search()
|
|
|
|
def create_search_result_from_spotify_track(self, spotify_track):
|
|
"""Create a mock search result object from Spotify track to work with downloads.py"""
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass
|
|
class MockSearchResult:
|
|
filename: str
|
|
user: str = "spotify_match"
|
|
size: int = 0
|
|
bit_rate: int = 0
|
|
sample_rate: int = 0
|
|
duration: int = 0
|
|
format: str = "flac"
|
|
title: str = ""
|
|
artist: str = ""
|
|
album: str = ""
|
|
track_number: int = 0
|
|
|
|
# Create mock search result with Spotify metadata
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
|
track_name = spotify_track.name
|
|
album_name = getattr(spotify_track, 'album', 'Unknown Album')
|
|
duration_seconds = int(spotify_track.duration_ms / 1000) if hasattr(spotify_track, 'duration_ms') else 0
|
|
|
|
search_result = MockSearchResult(
|
|
filename=f"{artist_name} - {track_name}.flac",
|
|
title=track_name,
|
|
artist=artist_name,
|
|
album=album_name,
|
|
duration=duration_seconds,
|
|
size=50000000, # Assume ~50MB for FLAC
|
|
bit_rate=1411, # Standard FLAC bitrate
|
|
sample_rate=44100,
|
|
format="flac"
|
|
)
|
|
|
|
return search_result
|
|
|
|
def create_spotify_based_search_result(self, original_search_result, spotify_track, spotify_artist):
|
|
"""Create a search result using Spotify metadata instead of Soulseek metadata"""
|
|
from dataclasses import dataclass
|
|
|
|
# Debug: Check what type of search result we received
|
|
print(f"🔍 Debug - original_search_result type: {type(original_search_result)}")
|
|
print(f"🔍 Debug - original_search_result attributes: {dir(original_search_result)}")
|
|
if hasattr(original_search_result, 'filename'):
|
|
print(f"🔍 Debug - filename: {original_search_result.filename}")
|
|
if hasattr(original_search_result, 'user'):
|
|
print(f"🔍 Debug - user: {original_search_result.user}")
|
|
else:
|
|
print(f"🔍 Debug - NO USER ATTRIBUTE FOUND")
|
|
|
|
@dataclass
|
|
class SpotifyBasedSearchResult:
|
|
# Soulseek download details - using expected field names
|
|
filename: str
|
|
username: str # downloads.py expects 'username' not 'user'
|
|
size: int
|
|
bitrate: int # downloads.py expects 'bitrate' not 'bit_rate'
|
|
sample_rate: int
|
|
duration: int
|
|
quality: str # downloads.py expects 'quality' not 'format'
|
|
|
|
# Spotify metadata for organization
|
|
title: str
|
|
artist: str
|
|
album: str
|
|
track_number: int = 0
|
|
|
|
# Add compatibility properties for any code expecting old names
|
|
@property
|
|
def user(self):
|
|
return self.username
|
|
|
|
@property
|
|
def bit_rate(self):
|
|
return self.bitrate
|
|
|
|
@property
|
|
def format(self):
|
|
return self.quality
|
|
|
|
# Get Spotify metadata
|
|
spotify_title = spotify_track.name
|
|
spotify_artist_name = spotify_artist.name
|
|
spotify_album = getattr(spotify_track, 'album', 'Unknown Album')
|
|
spotify_duration = int(spotify_track.duration_ms / 1000) if hasattr(spotify_track, 'duration_ms') else 0
|
|
|
|
# Determine track number if this is part of an album
|
|
track_number = getattr(spotify_track, 'track_number', 0) if hasattr(spotify_track, 'track_number') else 0
|
|
|
|
# Create hybrid result - Soulseek download data + Spotify metadata
|
|
# Map TrackResult attributes to expected format
|
|
spotify_based_result = SpotifyBasedSearchResult(
|
|
# Soulseek download details (keep for actual download) - map attributes correctly
|
|
filename=getattr(original_search_result, 'filename', f"{spotify_title}.flac"),
|
|
username=getattr(original_search_result, 'username', 'unknown_user'), # TrackResult uses 'username'
|
|
size=getattr(original_search_result, 'size', 50000000),
|
|
bitrate=getattr(original_search_result, 'bitrate', 1411), # TrackResult uses 'bitrate'
|
|
sample_rate=getattr(original_search_result, 'sample_rate', 44100),
|
|
duration=getattr(original_search_result, 'duration', spotify_duration),
|
|
quality=getattr(original_search_result, 'quality', 'flac'), # TrackResult uses 'quality'
|
|
|
|
# Spotify metadata (used for folder organization)
|
|
title=spotify_title,
|
|
artist=spotify_artist_name,
|
|
album=spotify_album,
|
|
track_number=track_number
|
|
)
|
|
|
|
print(f"🎯 Created Spotify-based search result:")
|
|
print(f" 📁 Title: {spotify_title} (Spotify)")
|
|
print(f" 🎤 Artist: {spotify_artist_name} (Spotify)")
|
|
print(f" 💿 Album: {spotify_album} (Spotify)")
|
|
print(f" 📄 File: {original_search_result.filename} (Soulseek)")
|
|
|
|
return spotify_based_result
|
|
|
|
def start_matched_download_via_infrastructure(self, search_result, track_index, table_index):
|
|
"""Start matched download using downloads.py infrastructure with Spotify metadata"""
|
|
try:
|
|
# Get the Spotify track for artist info
|
|
track_result = self.missing_tracks[track_index]
|
|
spotify_track = track_result.spotify_track
|
|
|
|
# Use the first artist from Spotify as the "matched" artist
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
|
|
|
# Create an Artist object compatible with downloads.py
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass
|
|
class SpotifyArtist:
|
|
name: str
|
|
id: str = ""
|
|
image_url: str = ""
|
|
popularity: int = 50
|
|
genres: list = None
|
|
|
|
def __post_init__(self):
|
|
if self.genres is None:
|
|
self.genres = []
|
|
|
|
artist = SpotifyArtist(name=artist_name)
|
|
|
|
# CREATE SPOTIFY-BASED SEARCH RESULT instead of using Soulseek metadata
|
|
# This ensures folder organization uses Spotify metadata, not Soulseek metadata
|
|
spotify_based_result = self.create_spotify_based_search_result(search_result, spotify_track, artist)
|
|
|
|
# ADD VALIDATION DATA to the search result for later verification
|
|
spotify_based_result.original_spotify_track = spotify_track # Store for validation
|
|
spotify_based_result.validation_required = True
|
|
|
|
# Call downloads.py infrastructure with Spotify-based search result
|
|
# This ensures proper folder organization using Spotify metadata
|
|
download_item = self.downloads_page._start_download_with_artist(spotify_based_result, artist)
|
|
|
|
if download_item:
|
|
print(f"✅ Successfully queued download for: {spotify_track.name}")
|
|
|
|
# Set up completion tracking
|
|
self.track_download_items = getattr(self, 'track_download_items', {})
|
|
self.track_download_items[download_item] = (track_index, table_index)
|
|
|
|
# Monitor download completion - but don't validate until actual completion
|
|
self.monitor_download_completion(download_item, track_index, table_index)
|
|
|
|
# Return download item for tracking
|
|
return download_item
|
|
else:
|
|
# Download failed to start
|
|
self.on_track_download_failed_infrastructure(track_index, table_index, "Failed to start download")
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error starting download via infrastructure: {str(e)}")
|
|
self.on_track_download_failed_infrastructure(track_index, table_index, str(e))
|
|
return None
|
|
|
|
def monitor_download_completion(self, download_item, track_index, table_index):
|
|
"""Monitor download completion by checking Transfer folder for completed files"""
|
|
from PyQt6.QtCore import QTimer
|
|
import os
|
|
|
|
# Get the expected transfer path for this track
|
|
if track_index < len(self.missing_tracks):
|
|
spotify_track = self.missing_tracks[track_index].spotify_track
|
|
expected_transfer_path = self.get_expected_transfer_path(spotify_track)
|
|
|
|
# Create timer to check for file existence in Transfer folder
|
|
timer = QTimer()
|
|
timer.expected_path = expected_transfer_path
|
|
timer.track_index = track_index
|
|
timer.table_index = table_index
|
|
timer.start_time = 0
|
|
timer.timeout.connect(lambda: self.check_transfer_folder_completion(timer))
|
|
timer.start(2000) # Check every 2 seconds (less frequent than queue checking)
|
|
|
|
# Store timer reference for cleanup
|
|
if not hasattr(self, 'download_timers'):
|
|
self.download_timers = []
|
|
self.download_timers.append(timer)
|
|
else:
|
|
print(f"❌ Track index {track_index} out of range for monitoring")
|
|
|
|
|
|
def get_expected_transfer_path(self, spotify_track):
|
|
"""Get the expected path where this track should appear in Transfer folder"""
|
|
import os
|
|
|
|
# Use the first artist for folder structure
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
|
track_name = spotify_track.name
|
|
|
|
# Clean names for file system
|
|
clean_artist = self.sanitize_filename(artist_name)
|
|
clean_track = self.sanitize_filename(track_name)
|
|
|
|
# Expected pattern: Transfer/ARTIST_NAME/ARTIST_NAME - TRACK_NAME/
|
|
# The actual filename will vary, so we'll check for the folder existence
|
|
expected_folder = os.path.join("Transfer", clean_artist, f"{clean_artist} - {clean_track}")
|
|
|
|
return expected_folder
|
|
|
|
def sanitize_filename(self, filename):
|
|
"""Clean filename for cross-platform compatibility"""
|
|
import re
|
|
# Remove or replace invalid characters
|
|
filename = re.sub(r'[<>:"/\\|?*]', '', filename)
|
|
filename = filename.strip()
|
|
return filename
|
|
|
|
def check_transfer_folder_completion(self, timer):
|
|
"""Check if the expected file/folder exists in Transfer directory"""
|
|
import os
|
|
import glob
|
|
|
|
try:
|
|
# Increment timer counter
|
|
timer.start_time += 2 # 2 seconds per check
|
|
|
|
# Check if expected folder exists
|
|
if os.path.exists(timer.expected_path):
|
|
# Check if there are any audio files in the folder
|
|
audio_files = glob.glob(os.path.join(timer.expected_path, "*.flac")) + \
|
|
glob.glob(os.path.join(timer.expected_path, "*.mp3")) + \
|
|
glob.glob(os.path.join(timer.expected_path, "*.wav"))
|
|
|
|
if audio_files:
|
|
print(f"✅ Transfer folder completion detected: {timer.expected_path}")
|
|
timer.stop()
|
|
self.on_track_download_complete_infrastructure(timer.track_index, timer.table_index)
|
|
return
|
|
|
|
# Timeout after 10 minutes
|
|
if timer.start_time > 600: # 10 minutes
|
|
print(f"⏰ Transfer folder check timeout for: {timer.expected_path}")
|
|
timer.stop()
|
|
self.on_track_download_failed_infrastructure(timer.track_index, timer.table_index, "Transfer timeout")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error checking transfer folder: {str(e)}")
|
|
timer.stop()
|
|
self.on_track_download_failed_infrastructure(timer.track_index, timer.table_index, str(e))
|
|
|
|
def check_download_status(self, download_item, track_index, table_index, timer):
|
|
"""DEPRECATED: Old download status checking - now using Transfer folder detection"""
|
|
# This method is kept for compatibility but not used anymore
|
|
print("⚠️ Using deprecated download status checking - should use Transfer folder detection")
|
|
timer.stop()
|
|
|
|
def check_download_status_async(self, download_item, track_index, table_index, timer):
|
|
"""Check download status using proper async handling"""
|
|
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
|
|
|
|
class DownloadStatusWorkerSignals(QObject):
|
|
status_checked = pyqtSignal(str, int, int, object) # state, track_index, table_index, timer
|
|
check_failed = pyqtSignal(str, int, int, object) # error, track_index, table_index, timer
|
|
|
|
class DownloadStatusWorker(QRunnable):
|
|
def __init__(self, soulseek_client, download_id):
|
|
super().__init__()
|
|
self.soulseek_client = soulseek_client
|
|
self.download_id = download_id
|
|
self.signals = DownloadStatusWorkerSignals()
|
|
|
|
def run(self):
|
|
loop = None
|
|
try:
|
|
import asyncio
|
|
# Create fresh event loop for this thread
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
# Get downloads with proper await
|
|
downloads = loop.run_until_complete(self.soulseek_client.get_all_downloads())
|
|
|
|
# Find our download
|
|
for download in downloads:
|
|
if download.id == self.download_id:
|
|
self.signals.status_checked.emit(download.state, track_index, table_index, timer)
|
|
return
|
|
|
|
# Download not found - might be completed already
|
|
self.signals.status_checked.emit("NotFound", track_index, table_index, timer)
|
|
|
|
except Exception as e:
|
|
self.signals.check_failed.emit(str(e), track_index, table_index, timer)
|
|
finally:
|
|
if loop:
|
|
try:
|
|
# Clean up
|
|
pending = asyncio.all_tasks(loop)
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
loop.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# Create and start worker
|
|
worker = DownloadStatusWorker(self.parent_page.soulseek_client, download_item.download_id)
|
|
worker.signals.status_checked.connect(self.on_download_status_checked)
|
|
worker.signals.check_failed.connect(self.on_download_status_check_failed)
|
|
|
|
# CRITICAL: Track worker for cancellation
|
|
if not hasattr(self, 'active_workers'):
|
|
self.active_workers = []
|
|
self.active_workers.append(worker)
|
|
|
|
# Submit to thread pool
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
thread_pool = QThreadPool()
|
|
self.fallback_pools.append(thread_pool)
|
|
thread_pool.start(worker)
|
|
|
|
def on_download_status_checked(self, state, track_index, table_index, timer):
|
|
"""Handle download status check result"""
|
|
if state == "Completed":
|
|
timer.stop()
|
|
self.on_track_download_complete_infrastructure(track_index, table_index)
|
|
elif state in ["Cancelled", "Failed"]:
|
|
timer.stop()
|
|
self.on_track_download_failed_infrastructure(track_index, table_index, f"Download {state.lower()}")
|
|
elif state == "NotFound":
|
|
timer.stop()
|
|
self.on_track_download_complete_infrastructure(track_index, table_index) # Assume completed
|
|
# For "InProgress" or other states, timer continues
|
|
|
|
def on_download_status_check_failed(self, error, track_index, table_index, timer):
|
|
"""Handle download status check failure"""
|
|
print(f"❌ Download status check failed: {error}")
|
|
timer.stop()
|
|
self.on_track_download_failed_infrastructure(track_index, table_index, f"Status check failed: {error}")
|
|
|
|
def on_track_download_complete_infrastructure(self, track_index, table_index):
|
|
"""Handle successful track download via infrastructure - ALREADY VALIDATED PRE-DOWNLOAD"""
|
|
print(f"✅ Download {track_index + 1} completed via infrastructure (pre-validated)")
|
|
|
|
# These downloads were already validated before starting, so mark as completed immediately
|
|
if table_index is not None:
|
|
downloaded_item = QTableWidgetItem("✅ Downloaded")
|
|
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, downloaded_item)
|
|
|
|
# Update console log
|
|
if hasattr(self.parent_page, 'log_area') and track_index < len(self.missing_tracks):
|
|
track = self.missing_tracks[track_index].spotify_track
|
|
track_name = track.name
|
|
artist_name = track.artists[0] if track.artists else "Unknown"
|
|
self.parent_page.log_area.append(f"✅ Downloaded & validated: {track_name} by {artist_name}")
|
|
|
|
# Update counters (these tracks were pre-validated, so count as successful)
|
|
self.downloaded_tracks_count += 1
|
|
if hasattr(self, 'downloaded_count_label'):
|
|
self.downloaded_count_label.setText(str(self.downloaded_tracks_count))
|
|
|
|
self.successful_downloads += 1
|
|
self.completed_downloads += 1
|
|
|
|
# Update progress and continue
|
|
self.advance_to_next_track()
|
|
|
|
def validate_downloaded_track(self, track_index, table_index, original_track):
|
|
"""Validate that downloaded track matches original Spotify track via API lookup"""
|
|
print(f"🎯 Starting validation: {original_track.name} by {original_track.artists[0] if original_track.artists else 'Unknown'}")
|
|
|
|
# Update table to show validation in progress - ONLY when download is actually complete
|
|
if table_index is not None:
|
|
validating_item = QTableWidgetItem("🔍 Validating")
|
|
validating_item.setFlags(validating_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
validating_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, validating_item)
|
|
|
|
# Update console
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
track_name = original_track.name
|
|
artist_name = original_track.artists[0] if original_track.artists else "Unknown"
|
|
self.parent_page.log_area.append(f"🔍 Validating download: {track_name} by {artist_name}")
|
|
|
|
# Start Spotify validation worker
|
|
self.start_spotify_validation_worker(track_index, table_index, original_track)
|
|
|
|
def validate_slskd_result_with_spotify(self, slskd_result, original_spotify_track, track_index, table_index, valid_candidates):
|
|
"""
|
|
Pre-download validation: Extract track title from slskd result and validate with Spotify API
|
|
|
|
Flow:
|
|
1. Extract track title from slskd result filename
|
|
2. Search Spotify API with extracted title
|
|
3. Compare Spotify API results with original playlist track artist
|
|
4. If validation passes: use Spotify metadata for download
|
|
5. If validation fails: try next candidate or fail gracefully
|
|
"""
|
|
print(f"🔍 PRE-DOWNLOAD VALIDATION: {slskd_result.filename}")
|
|
|
|
# Extract track title from slskd result filename using existing parsing logic
|
|
extracted_title = self.extract_track_title_from_filename(slskd_result.filename)
|
|
extracted_artist = getattr(slskd_result, 'artist', '') or self.extract_artist_from_filename(slskd_result.filename)
|
|
|
|
print(f" 📄 Extracted from slskd: '{extracted_title}' by '{extracted_artist}'")
|
|
|
|
# Get the actual Spotify track from TrackAnalysisResult
|
|
spotify_track = original_spotify_track.spotify_track if hasattr(original_spotify_track, 'spotify_track') else original_spotify_track
|
|
print(f" 📋 Original playlist: '{spotify_track.name}' by '{spotify_track.artists[0] if spotify_track.artists else 'Unknown'}'")
|
|
|
|
# Start validation worker
|
|
self.start_pre_download_validation_worker(
|
|
slskd_result, spotify_track, extracted_title, extracted_artist,
|
|
track_index, table_index, valid_candidates
|
|
)
|
|
|
|
def extract_track_title_from_filename(self, filename):
|
|
"""Extract track title from slskd result filename using existing parsing logic"""
|
|
if not filename:
|
|
return ""
|
|
|
|
# Use existing normalize_track_title logic but keep more information
|
|
title = filename
|
|
|
|
# Remove file extension
|
|
import re
|
|
title = re.sub(r'\.(flac|mp3|aac|alac|ape|ogg|m4a)$', '', title, flags=re.IGNORECASE)
|
|
|
|
# Remove track numbers from beginning first (before artist-track parsing)
|
|
title = re.sub(r'^(\d{1,4}[-.\s]*)', '', title)
|
|
|
|
# Handle "Artist - Track" format (most common)
|
|
if ' - ' in title:
|
|
parts = title.split(' - ')
|
|
if len(parts) >= 2:
|
|
# Second part is usually the track title
|
|
title = parts[1].strip()
|
|
# If there are more parts, include them (could be track title with dashes)
|
|
if len(parts) > 2:
|
|
title = ' - '.join(parts[1:]).strip()
|
|
|
|
# Remove common quality indicators
|
|
title = re.sub(r'\[(320|256|192|128)k?bps?\]', '', title, flags=re.IGNORECASE)
|
|
title = re.sub(r'\[flac\]', '', title, flags=re.IGNORECASE)
|
|
title = re.sub(r'\[24bit\]', '', title, flags=re.IGNORECASE)
|
|
title = re.sub(r'\[hi-?res\]', '', title, flags=re.IGNORECASE)
|
|
|
|
# Clean up whitespace
|
|
title = re.sub(r'\s+', ' ', title).strip()
|
|
|
|
return title
|
|
|
|
def extract_artist_from_filename(self, filename):
|
|
"""Extract artist name from slskd result filename"""
|
|
if not filename:
|
|
return ""
|
|
|
|
import re
|
|
|
|
# Remove file extension
|
|
title = re.sub(r'\.(flac|mp3|aac|alac|ape|ogg|m4a)$', '', filename, flags=re.IGNORECASE)
|
|
|
|
# Handle "Artist - Track" format (most common)
|
|
if ' - ' in title:
|
|
parts = title.split(' - ')
|
|
if len(parts) >= 2:
|
|
# First part is usually the artist
|
|
artist = parts[0].strip()
|
|
return artist
|
|
|
|
# If no clear artist-track separation, return empty (will rely on slskd result.artist field)
|
|
return ""
|
|
|
|
def start_pre_download_validation_worker(self, slskd_result, original_spotify_track, extracted_title, extracted_artist, track_index, table_index, valid_candidates):
|
|
"""Start background worker for pre-download Spotify API validation"""
|
|
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
|
|
|
|
class PreDownloadValidationSignals(QObject):
|
|
validation_completed = pyqtSignal(bool, object, str, int, int, list) # is_valid, spotify_metadata, reason, track_index, table_index, valid_candidates
|
|
validation_failed = pyqtSignal(str, int, int, object, list) # error, track_index, table_index, original_track, valid_candidates
|
|
|
|
class PreDownloadValidationWorker(QRunnable):
|
|
def __init__(self, spotify_client, slskd_result, original_spotify_track, extracted_title, extracted_artist):
|
|
super().__init__()
|
|
self.spotify_client = spotify_client
|
|
self.slskd_result = slskd_result
|
|
self.original_spotify_track = original_spotify_track
|
|
self.extracted_title = extracted_title
|
|
self.extracted_artist = extracted_artist
|
|
self.signals = PreDownloadValidationSignals()
|
|
|
|
def run(self):
|
|
try:
|
|
# Use multiple search strategies for better results
|
|
spotify_results = []
|
|
|
|
# Strategy 1: Artist + Track (recommended format: "Virtual Mage Orbit Love")
|
|
if self.extracted_artist:
|
|
search_query_1 = f"{self.extracted_artist} {self.extracted_title}"
|
|
print(f"🔍 Spotify API search (Strategy 1): '{search_query_1}'")
|
|
spotify_results = self.spotify_client.search_tracks(search_query_1, limit=10)
|
|
|
|
# Strategy 2: Track + Shortened Artist (fallback: "Orbit Love Virtual M")
|
|
if not spotify_results and self.extracted_artist:
|
|
artist_words = self.extracted_artist.split()
|
|
if len(artist_words) > 1:
|
|
short_artist = artist_words[0] + " " + artist_words[1][0] if len(artist_words[1]) > 0 else artist_words[0]
|
|
else:
|
|
short_artist = artist_words[0][:7] if len(artist_words[0]) > 7 else artist_words[0]
|
|
|
|
search_query_2 = f"{self.extracted_title} {short_artist}"
|
|
print(f"🔍 Spotify API search (Strategy 2): '{search_query_2}'")
|
|
spotify_results = self.spotify_client.search_tracks(search_query_2, limit=10)
|
|
|
|
# Strategy 3: Track only (final fallback: "Orbit Love")
|
|
if not spotify_results:
|
|
search_query_3 = self.extracted_title
|
|
print(f"🔍 Spotify API search (Strategy 3): '{search_query_3}'")
|
|
spotify_results = self.spotify_client.search_tracks(search_query_3, limit=10)
|
|
|
|
if not spotify_results:
|
|
self.signals.validation_completed.emit(False, None, "No Spotify API results found with any search strategy", track_index, table_index, valid_candidates)
|
|
return
|
|
|
|
# Get original playlist track artist for comparison
|
|
original_artist = self.original_spotify_track.artists[0] if self.original_spotify_track.artists else ""
|
|
original_artist_clean = original_artist.lower().strip()
|
|
|
|
print(f"🔍 Comparing against original artist: '{original_artist}'")
|
|
|
|
# Check if any Spotify result matches the original playlist track artist
|
|
best_spotify_match = None
|
|
for result in spotify_results:
|
|
if result.artists:
|
|
spotify_api_artist = result.artists[0].lower().strip()
|
|
|
|
# Exact artist match gives highest confidence
|
|
if spotify_api_artist == original_artist_clean:
|
|
print(f"✅ PERFECT MATCH: Spotify API confirms '{result.name}' by '{result.artists[0]}'")
|
|
best_spotify_match = result
|
|
break
|
|
|
|
# Partial artist match (for cases like "Virtual Mage" vs "Virtual Mage Official")
|
|
elif original_artist_clean in spotify_api_artist or spotify_api_artist in original_artist_clean:
|
|
print(f"✅ PARTIAL MATCH: Spotify API found '{result.name}' by '{result.artists[0]}' (close to '{original_artist}')")
|
|
if not best_spotify_match: # Take first partial match if no exact match
|
|
best_spotify_match = result
|
|
|
|
if best_spotify_match:
|
|
# Validation passed - return Spotify metadata for proper folder organization
|
|
self.signals.validation_completed.emit(True, best_spotify_match, f"Validated via Spotify API: {best_spotify_match.artists[0]}", track_index, table_index, valid_candidates)
|
|
else:
|
|
# No artist match found
|
|
found_artists = [r.artists[0] if r.artists else "Unknown" for r in spotify_results[:3]]
|
|
reason = f"Artist mismatch. Expected: '{original_artist}', Spotify API returned: {found_artists}"
|
|
self.signals.validation_completed.emit(False, None, reason, track_index, table_index, valid_candidates)
|
|
|
|
except Exception as e:
|
|
self.signals.validation_failed.emit(str(e), track_index, table_index, self.original_spotify_track, valid_candidates)
|
|
|
|
# Create and start validation worker
|
|
spotify_client = getattr(self.parent_page, 'spotify_client', None)
|
|
if not spotify_client:
|
|
print("❌ No Spotify client available for pre-download validation")
|
|
self.on_predownload_validation_failed("No Spotify client available", track_index, table_index, original_spotify_track, valid_candidates)
|
|
return
|
|
|
|
worker = PreDownloadValidationWorker(spotify_client, slskd_result, original_spotify_track, extracted_title, extracted_artist)
|
|
worker.signals.validation_completed.connect(self.on_predownload_validation_completed)
|
|
worker.signals.validation_failed.connect(self.on_predownload_validation_failed)
|
|
|
|
# Use thread pool for background execution
|
|
from PyQt6.QtCore import QThreadPool
|
|
thread_pool = QThreadPool.globalInstance()
|
|
thread_pool.start(worker)
|
|
|
|
def on_predownload_validation_completed(self, is_valid, spotify_metadata, reason, track_index, table_index, valid_candidates):
|
|
"""Handle pre-download Spotify validation completion"""
|
|
print(f"🔍 Pre-download validation result for track {track_index + 1}: {'✅ VALID' if is_valid else '❌ INVALID'}")
|
|
print(f" Reason: {reason}")
|
|
|
|
if is_valid and spotify_metadata:
|
|
# Validation passed - proceed with download using Spotify metadata
|
|
print(f"✅ Validation passed! Using Spotify metadata for download: '{spotify_metadata.name}' by '{spotify_metadata.artists[0] if spotify_metadata.artists else 'Unknown'}'")
|
|
|
|
# Get the original slskd result (first candidate that was being validated)
|
|
original_slskd_result = valid_candidates[0] if valid_candidates else None
|
|
if not original_slskd_result:
|
|
print("❌ No slskd result available for download")
|
|
self.try_next_candidate_or_fail(track_index, table_index, valid_candidates, "No slskd result available")
|
|
return
|
|
|
|
# Start download with validated Spotify metadata
|
|
self.start_download_with_validated_spotify_metadata(original_slskd_result, spotify_metadata, track_index, table_index)
|
|
|
|
else:
|
|
# Validation failed - try next candidate from valid_candidates list
|
|
print(f"❌ Validation failed: {reason}")
|
|
self.try_next_candidate_or_fail(track_index, table_index, valid_candidates, reason)
|
|
|
|
def on_predownload_validation_failed(self, error, track_index, table_index, original_track, valid_candidates):
|
|
"""Handle pre-download Spotify validation error"""
|
|
print(f"❌ Pre-download validation error for track {track_index + 1}: {error}")
|
|
|
|
# Try next candidate or fail gracefully
|
|
self.try_next_candidate_or_fail(track_index, table_index, valid_candidates, f"Validation error: {error}")
|
|
|
|
def try_next_candidate_or_fail(self, track_index, table_index, valid_candidates, reason):
|
|
"""Try next candidate from valid_candidates list or fail gracefully"""
|
|
# Remove the failed candidate
|
|
if valid_candidates and len(valid_candidates) > 1:
|
|
failed_candidate = valid_candidates.pop(0) # Remove first (failed) candidate
|
|
print(f"🔄 Trying next candidate. Remaining candidates: {len(valid_candidates)}")
|
|
|
|
# Try validation with next candidate
|
|
next_candidate = valid_candidates[0]
|
|
track_analysis_result = self.missing_tracks[track_index] if track_index < len(self.missing_tracks) else None
|
|
|
|
if track_analysis_result:
|
|
print(f"🔄 Retrying with next candidate: {next_candidate.filename}")
|
|
self.validate_slskd_result_with_spotify(next_candidate, track_analysis_result, track_index, table_index, valid_candidates)
|
|
return
|
|
|
|
# No more candidates available - mark as failed
|
|
print(f"❌ All candidates failed for track {track_index + 1}: {reason}")
|
|
self.on_track_download_failed(track_index, table_index, reason)
|
|
|
|
def start_download_with_validated_spotify_metadata(self, slskd_result, spotify_metadata, track_index, table_index):
|
|
"""Start download using validated Spotify metadata for proper folder organization"""
|
|
print(f"🚀 Starting validated download: '{spotify_metadata.name}' by '{spotify_metadata.artists[0] if spotify_metadata.artists else 'Unknown'}'")
|
|
|
|
# Create a Spotify-based search result that combines slskd download details with Spotify metadata
|
|
# This ensures the download uses validated Spotify metadata for folder structure
|
|
spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata)
|
|
|
|
# Update table to show downloading status (don't mark as downloaded yet)
|
|
if table_index is not None and table_index < self.track_table.rowCount():
|
|
downloading_item = QTableWidgetItem("⏬ Downloading")
|
|
downloading_item.setFlags(downloading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloading_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, downloading_item)
|
|
|
|
# Log the download start
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f"🎵 Downloading: {spotify_based_result.filename} (validated)")
|
|
|
|
# Use downloads.py infrastructure for the actual download with validated metadata
|
|
download_id = self.start_matched_download_via_infrastructure(spotify_based_result, track_index, table_index)
|
|
|
|
# The download is already being monitored by the existing monitor_download_completion method
|
|
# No additional tracking needed here
|
|
|
|
# Move to next track search
|
|
self.advance_to_next_track()
|
|
|
|
def create_spotify_based_search_result_from_validation(self, slskd_result, spotify_metadata):
|
|
"""Create SpotifyBasedSearchResult from validation results"""
|
|
from types import SimpleNamespace
|
|
|
|
# Extract Spotify metadata
|
|
spotify_title = spotify_metadata.name
|
|
spotify_artist = spotify_metadata.artists[0] if spotify_metadata.artists else "Unknown Artist"
|
|
spotify_album_name = getattr(spotify_metadata, 'album', spotify_title) # album is already a string in Track object
|
|
spotify_duration = getattr(spotify_metadata, 'duration_ms', 0) // 1000 if hasattr(spotify_metadata, 'duration_ms') else 0
|
|
|
|
# Create hybrid result with slskd download details + Spotify metadata for organization
|
|
class SpotifyBasedSearchResult:
|
|
def __init__(self):
|
|
# Soulseek download details (for actual download)
|
|
self.filename = getattr(slskd_result, 'filename', f"{spotify_title}.flac")
|
|
self.username = getattr(slskd_result, 'username', getattr(slskd_result, 'user', 'unknown_user'))
|
|
self.size = getattr(slskd_result, 'size', 50000000)
|
|
self.bit_rate = getattr(slskd_result, 'bit_rate', 1411)
|
|
self.sample_rate = getattr(slskd_result, 'sample_rate', 44100)
|
|
self.duration = getattr(slskd_result, 'duration', spotify_duration)
|
|
self.quality = getattr(slskd_result, 'quality', getattr(slskd_result, 'format', 'flac'))
|
|
|
|
# Spotify metadata (for folder organization)
|
|
self.spotify_title = spotify_title
|
|
self.spotify_artist = spotify_artist
|
|
self.spotify_album = spotify_album_name
|
|
self.spotify_duration = spotify_duration
|
|
self.spotify_id = getattr(spotify_metadata, 'id', None)
|
|
|
|
# For compatibility with existing infrastructure
|
|
self.title = spotify_title
|
|
self.artist = spotify_artist
|
|
self.album = spotify_album_name
|
|
|
|
return SpotifyBasedSearchResult()
|
|
|
|
|
|
def start_spotify_validation_worker(self, track_index, table_index, original_track):
|
|
"""Start background worker to validate track via Spotify API"""
|
|
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
|
|
|
|
class SpotifyValidationWorkerSignals(QObject):
|
|
validation_completed = pyqtSignal(bool, str, int, int, object) # is_valid, reason, track_index, table_index, original_track
|
|
validation_failed = pyqtSignal(str, int, int, object) # error, track_index, table_index, original_track
|
|
|
|
class SpotifyValidationWorker(QRunnable):
|
|
def __init__(self, spotify_client, original_track):
|
|
super().__init__()
|
|
self.spotify_client = spotify_client
|
|
self.original_track = original_track
|
|
self.signals = SpotifyValidationWorkerSignals()
|
|
|
|
def run(self):
|
|
try:
|
|
# Search Spotify for the track to get API response
|
|
original_artist = self.original_track.artists[0] if self.original_track.artists else ""
|
|
original_title = self.original_track.name
|
|
|
|
print(f"🔍 Spotify API lookup: '{original_title}' by '{original_artist}'")
|
|
|
|
# Search Spotify API for this track
|
|
search_query = f"track:{original_title} artist:{original_artist}"
|
|
spotify_results = self.spotify_client.search_tracks(search_query, limit=5)
|
|
|
|
if not spotify_results:
|
|
self.signals.validation_completed.emit(False, "No Spotify API results found", track_index, table_index, self.original_track)
|
|
return
|
|
|
|
# Check if any result matches our original track artist
|
|
for result in spotify_results:
|
|
if result.artists:
|
|
spotify_api_artist = result.artists[0].lower().strip()
|
|
original_artist_clean = original_artist.lower().strip()
|
|
|
|
# Exact match validation
|
|
if spotify_api_artist == original_artist_clean:
|
|
print(f"✅ Validation passed: Spotify API confirms '{result.name}' by '{result.artists[0]}'")
|
|
self.signals.validation_completed.emit(True, f"Spotify API confirmed artist match: {result.artists[0]}", track_index, table_index, self.original_track)
|
|
return
|
|
|
|
# No matching artist found
|
|
found_artists = [r.artists[0] if r.artists else "Unknown" for r in spotify_results[:3]]
|
|
reason = f"Artist mismatch. Expected: '{original_artist}', Spotify API returned: {found_artists}"
|
|
self.signals.validation_completed.emit(False, reason, track_index, table_index, self.original_track)
|
|
|
|
except Exception as e:
|
|
self.signals.validation_failed.emit(str(e), track_index, table_index, self.original_track)
|
|
|
|
# Create and start validation worker
|
|
spotify_client = getattr(self.parent_page, 'spotify_client', None)
|
|
if not spotify_client:
|
|
print("❌ No Spotify client available for validation")
|
|
self.on_validation_failed("No Spotify client available", track_index, table_index, original_track)
|
|
return
|
|
|
|
worker = SpotifyValidationWorker(spotify_client, original_track)
|
|
worker.signals.validation_completed.connect(self.on_validation_completed)
|
|
worker.signals.validation_failed.connect(self.on_validation_failed)
|
|
|
|
# CRITICAL: Track worker for cancellation
|
|
if not hasattr(self, 'active_workers'):
|
|
self.active_workers = []
|
|
self.active_workers.append(worker)
|
|
|
|
# Submit to thread pool
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
thread_pool = QThreadPool()
|
|
self.fallback_pools.append(thread_pool)
|
|
thread_pool.start(worker)
|
|
|
|
def on_validation_completed(self, is_valid, reason, track_index, table_index, original_track):
|
|
"""Handle Spotify validation completion"""
|
|
if is_valid:
|
|
# Validation passed - mark as truly completed
|
|
self.successful_downloads += 1
|
|
|
|
print(f"✅ Validation passed for track {track_index + 1}: {reason}")
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
track_name = original_track.name
|
|
artist_name = original_track.artists[0] if original_track.artists else "Unknown"
|
|
self.parent_page.log_area.append(f"✅ Downloaded & validated: {track_name} by {artist_name}")
|
|
|
|
# Update table row
|
|
if table_index is not None:
|
|
downloaded_item = QTableWidgetItem("✅ Downloaded")
|
|
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, downloaded_item)
|
|
|
|
# Update downloaded counter in dashboard
|
|
self.downloaded_tracks_count += 1
|
|
if hasattr(self, 'downloaded_count_label'):
|
|
self.downloaded_count_label.setText(str(self.downloaded_tracks_count))
|
|
|
|
# Update progress
|
|
self.completed_downloads += 1
|
|
self.advance_to_next_track()
|
|
|
|
else:
|
|
# Validation failed - try next search candidate
|
|
print(f"❌ Validation failed for track {track_index + 1}: {reason}")
|
|
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
track_name = original_track.name
|
|
artist_name = original_track.artists[0] if original_track.artists else "Unknown"
|
|
self.parent_page.log_area.append(f"❌ Validation failed: {track_name} by {artist_name} - {reason}")
|
|
self.parent_page.log_area.append(f"🔄 Trying next search candidate...")
|
|
|
|
# Try next search candidate
|
|
self.retry_with_next_candidate(track_index, table_index, original_track, reason)
|
|
|
|
def on_validation_failed(self, error, track_index, table_index, original_track):
|
|
"""Handle Spotify validation error"""
|
|
print(f"❌ Validation error for track {track_index + 1}: {error}")
|
|
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
track_name = original_track.name
|
|
artist_name = original_track.artists[0] if original_track.artists else "Unknown"
|
|
self.parent_page.log_area.append(f"❌ Validation error: {track_name} by {artist_name} - {error}")
|
|
self.parent_page.log_area.append(f"🔄 Trying next search candidate...")
|
|
|
|
# Try next search candidate
|
|
self.retry_with_next_candidate(track_index, table_index, original_track, error)
|
|
|
|
def retry_with_next_candidate(self, track_index, table_index, original_track, reason):
|
|
"""Try the next search candidate for this track"""
|
|
print(f"🔄 Retrying track {track_index + 1} with next search candidate")
|
|
|
|
# Update table to show retry in progress
|
|
if table_index is not None:
|
|
retry_item = QTableWidgetItem("🔄 Retrying")
|
|
retry_item.setFlags(retry_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
retry_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, retry_item)
|
|
|
|
# Check if we have already stored search results for this track
|
|
if not hasattr(self, 'track_search_results'):
|
|
self.track_search_results = {}
|
|
|
|
if not hasattr(self, 'track_candidate_index'):
|
|
self.track_candidate_index = {}
|
|
|
|
# Initialize candidate index if first retry
|
|
if track_index not in self.track_candidate_index:
|
|
self.track_candidate_index[track_index] = 0
|
|
|
|
# Move to next candidate
|
|
self.track_candidate_index[track_index] += 1
|
|
|
|
# Check if we have more candidates to try
|
|
if track_index in self.track_search_results:
|
|
candidates = self.track_search_results[track_index]
|
|
current_index = self.track_candidate_index[track_index]
|
|
|
|
if current_index < len(candidates):
|
|
# Try next candidate
|
|
next_candidate = candidates[current_index]
|
|
print(f"🎯 Trying candidate {current_index + 1}/{len(candidates)}: {next_candidate.filename}")
|
|
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f" 🎯 Trying candidate {current_index + 1}/{len(candidates)}: {next_candidate.filename}")
|
|
|
|
# Start download with next candidate
|
|
self.start_download_with_match(next_candidate, original_track, track_index, table_index)
|
|
return
|
|
|
|
# No more candidates available - mark as failed
|
|
print(f"❌ No more candidates available for track {track_index + 1}")
|
|
self.on_track_download_failed_infrastructure(track_index, table_index, f"All candidates failed validation. Last reason: {reason}")
|
|
|
|
def on_track_download_failed_infrastructure(self, track_index, table_index, error_message):
|
|
"""Handle failed track download via infrastructure"""
|
|
self.failed_downloads += 1
|
|
|
|
print(f"❌ Download {track_index + 1} failed via infrastructure: {error_message}")
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area') and track_index < len(self.missing_tracks):
|
|
track = self.missing_tracks[track_index].spotify_track
|
|
track_name = track.name
|
|
artist_name = track.artists[0] if track.artists else "Unknown Artist"
|
|
self.parent_page.log_area.append(f"❌ Download failed: {track_name} by {artist_name} - {error_message}")
|
|
|
|
# Update table row
|
|
if table_index is not None:
|
|
failed_item = QTableWidgetItem("❌ Failed")
|
|
failed_item.setFlags(failed_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
failed_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, failed_item)
|
|
|
|
def download_next_track(self):
|
|
"""Download the next missing track"""
|
|
if self.current_download >= len(self.missing_tracks):
|
|
# All downloads complete
|
|
self.on_all_downloads_complete()
|
|
return
|
|
|
|
track_result = self.missing_tracks[self.current_download]
|
|
track = track_result.spotify_track
|
|
track_index = self.find_track_index(track)
|
|
|
|
print(f"🎵 Downloading track {self.current_download + 1}/{len(self.missing_tracks)}: {track.name}")
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
artist_name = track.artists[0] if track.artists else "Unknown Artist"
|
|
progress_pct = ((self.current_download + 1) / len(self.missing_tracks)) * 100
|
|
self.parent_page.log_area.append(f"🎵 Downloading ({self.current_download + 1}/{len(self.missing_tracks)}, {progress_pct:.0f}%): {track.name} by {artist_name}")
|
|
|
|
# Update table to show downloading status
|
|
if track_index is not None:
|
|
downloading_item = QTableWidgetItem("⏬ Downloading")
|
|
downloading_item.setFlags(downloading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloading_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(track_index, 4, downloading_item)
|
|
|
|
# DEPRECATED: Now using parallel downloads for better performance
|
|
print("⚠️ download_next_track() called - switching to parallel downloads")
|
|
if not hasattr(self, 'parallel_started'):
|
|
self.parallel_started = True
|
|
self.start_parallel_downloads()
|
|
|
|
def start_parallel_downloads(self):
|
|
"""Start multiple track downloads in parallel for better performance"""
|
|
max_concurrent = 3 # Conservative limit to avoid overwhelming services
|
|
|
|
print(f"🚀 Starting parallel downloads: {max_concurrent} concurrent tracks")
|
|
|
|
# Initialize parallel tracking
|
|
if not hasattr(self, 'active_parallel_downloads'):
|
|
self.active_parallel_downloads = 0
|
|
if not hasattr(self, 'download_queue_index'):
|
|
self.download_queue_index = 0
|
|
if not hasattr(self, 'failed_downloads'):
|
|
self.failed_downloads = 0
|
|
if not hasattr(self, 'completed_downloads'):
|
|
self.completed_downloads = 0
|
|
if not hasattr(self, 'successful_downloads'):
|
|
self.successful_downloads = 0
|
|
|
|
# Start initial batch of downloads
|
|
self.start_next_batch_of_downloads(max_concurrent)
|
|
|
|
def start_next_batch_of_downloads(self, max_concurrent):
|
|
"""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)):
|
|
|
|
# Get next track to download
|
|
track_result = self.missing_tracks[self.download_queue_index]
|
|
track = track_result.spotify_track
|
|
track_index = self.find_track_index(track)
|
|
|
|
print(f"🎵 Starting parallel download {self.download_queue_index + 1}/{len(self.missing_tracks)}: {track.name}")
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
artist_name = track.artists[0] if track.artists else "Unknown Artist"
|
|
progress_pct = ((self.download_queue_index + 1) / len(self.missing_tracks)) * 100
|
|
self.parent_page.log_area.append(f"🎵 Searching ({self.download_queue_index + 1}/{len(self.missing_tracks)}, {progress_pct:.0f}%): {track.name} by {artist_name}")
|
|
|
|
# Update table to show searching status
|
|
if track_index is not None:
|
|
searching_item = QTableWidgetItem("🔍 Searching")
|
|
searching_item.setFlags(searching_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
searching_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(track_index, 4, searching_item)
|
|
|
|
# Start parallel search and download
|
|
self.search_and_download_track_parallel(track, self.download_queue_index, track_index)
|
|
|
|
# Increment counters
|
|
self.active_parallel_downloads += 1
|
|
self.download_queue_index += 1
|
|
|
|
# Check if all downloads are complete
|
|
if (self.download_queue_index >= len(self.missing_tracks) and
|
|
self.active_parallel_downloads == 0):
|
|
self.on_all_downloads_complete()
|
|
|
|
def search_and_download_track_with_infrastructure(self, spotify_track, download_index, track_index):
|
|
"""Search for track and download via infrastructure path (with Transfer folder organization)"""
|
|
print(f"🔍 Starting search + infrastructure download for: {spotify_track.name}")
|
|
|
|
# Create search queries using the smart strategy
|
|
track_name = spotify_track.name
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
|
|
search_queries = self.generate_smart_search_queries(track_name, artist_name)
|
|
|
|
# Start the search process using the existing infrastructure
|
|
# This will trigger search → validation → download → Transfer folder organization
|
|
self.start_track_search_with_queries(spotify_track, search_queries, track_index, track_index)
|
|
|
|
def search_and_download_track_parallel(self, spotify_track, download_index, track_index):
|
|
"""Search for track and download via infrastructure path - PARALLEL VERSION"""
|
|
print(f"🚀 Starting parallel search for: {spotify_track.name}")
|
|
|
|
# Create search queries using the smart strategy
|
|
track_name = spotify_track.name
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
|
|
search_queries = self.generate_smart_search_queries(track_name, artist_name)
|
|
|
|
# Start the search process with parallel completion handling
|
|
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"""
|
|
print(f"🔍 Starting parallel search with {len(search_queries)} queries: {search_queries[0] if search_queries else 'no queries'}")
|
|
|
|
# Store parallel tracking info
|
|
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() # Track sources to avoid retrying same ones
|
|
}
|
|
|
|
# Use existing search infrastructure but with parallel completion callback
|
|
self.start_search_worker_parallel(search_queries[0], search_queries, spotify_track, track_index, table_index, 0, download_index)
|
|
|
|
def start_search_worker_parallel(self, query, queries, spotify_track, track_index, table_index, query_index, download_index):
|
|
"""Start search worker with parallel completion handling"""
|
|
print(f"🔍 Starting parallel search worker for query: {query}")
|
|
|
|
# Create a simple parallel search worker
|
|
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
|
|
|
|
class ParallelSearchWorkerSignals(QObject):
|
|
search_completed = pyqtSignal(list, str) # results, query
|
|
search_failed = pyqtSignal(str, str) # query, error
|
|
|
|
class ParallelSearchWorker(QRunnable):
|
|
def __init__(self, soulseek_client, query):
|
|
super().__init__()
|
|
self.soulseek_client = soulseek_client
|
|
self.query = query
|
|
self.signals = ParallelSearchWorkerSignals()
|
|
|
|
def run(self):
|
|
loop = None
|
|
try:
|
|
import asyncio
|
|
# Create a fresh event loop for this thread
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
# Perform search with proper await
|
|
search_result = loop.run_until_complete(self.soulseek_client.search(self.query))
|
|
|
|
# Debug: Check what we actually got
|
|
print(f"🔍 DEBUG: Search '{self.query}' returned type: {type(search_result)}")
|
|
|
|
# Handle tuple result structure (tracks, albums)
|
|
if isinstance(search_result, tuple) and len(search_result) >= 1:
|
|
# First element is the tracks list
|
|
results_list = search_result[0] if search_result[0] else []
|
|
print(f"🔍 DEBUG: Extracted {len(results_list)} tracks from tuple")
|
|
elif hasattr(search_result, '__iter__') and not isinstance(search_result, (str, bytes)):
|
|
results_list = list(search_result)
|
|
else:
|
|
results_list = []
|
|
|
|
# Debug first actual result
|
|
if results_list and len(results_list) > 0:
|
|
first_track = results_list[0]
|
|
print(f"🔍 DEBUG: First track type: {type(first_track)}")
|
|
print(f"🔍 DEBUG: First track attributes: {[attr for attr in dir(first_track) if not attr.startswith('_')]}")
|
|
if hasattr(first_track, 'filename'):
|
|
print(f"🔍 DEBUG: First track filename: '{first_track.filename}'")
|
|
|
|
# Emit completion signal
|
|
self.signals.search_completed.emit(results_list, self.query)
|
|
|
|
except Exception as e:
|
|
print(f"❌ Parallel search error for '{self.query}': {str(e)}")
|
|
self.signals.search_failed.emit(self.query, str(e))
|
|
finally:
|
|
if loop:
|
|
try:
|
|
pending = asyncio.all_tasks(loop)
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
loop.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# Create and configure worker
|
|
worker = ParallelSearchWorker(self.parent_page.soulseek_client, query)
|
|
|
|
# Connect to parallel completion handler
|
|
worker.signals.search_completed.connect(
|
|
lambda results, q, qs=queries, st=spotify_track, ti=track_index,
|
|
tabi=table_index, qi=query_index, di=download_index:
|
|
self.on_search_query_completed_parallel(results, qs, st, ti, tabi, qi, q, di)
|
|
)
|
|
|
|
# Handle search failures
|
|
worker.signals.search_failed.connect(
|
|
lambda q, error, di=download_index:
|
|
self.on_parallel_track_failed(di, f"Search failed for '{q}': {error}")
|
|
)
|
|
|
|
# Submit to thread pool
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
thread_pool = QThreadPool()
|
|
self.fallback_pools.append(thread_pool)
|
|
thread_pool.start(worker)
|
|
|
|
return 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 hasattr(self, 'cancel_requested') and self.cancel_requested:
|
|
print("🛑 Parallel search query completed after cancellation - ignoring results")
|
|
self.on_parallel_track_completed(download_index, False)
|
|
return
|
|
|
|
print(f"✅ Parallel search query '{query}' returned {len(results)} results for download {download_index + 1}")
|
|
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f" ✅ Found {len(results)} tracks for '{query}' (parallel #{download_index + 1})")
|
|
|
|
if results and len(results) > 0:
|
|
valid_candidates = self.get_valid_candidates(results, spotify_track, query)
|
|
|
|
if valid_candidates:
|
|
print(f"🎯 Found {len(valid_candidates)} valid candidates for parallel download {download_index + 1}")
|
|
|
|
# --- NEW CODE: Cache the candidates and the original spotify track ---
|
|
if hasattr(self, 'parallel_search_tracking') and download_index in self.parallel_search_tracking:
|
|
self.parallel_search_tracking[download_index]['candidates'] = valid_candidates
|
|
self.parallel_search_tracking[download_index]['spotify_track'] = spotify_track
|
|
# --- END OF NEW CODE ---
|
|
|
|
best_match = valid_candidates[0]
|
|
print(f"🎯 Selected best match for parallel download: {best_match.filename}")
|
|
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f" 🎯 Best match (parallel #{download_index + 1}): {best_match.filename}")
|
|
|
|
self.validate_slskd_result_with_spotify_parallel(best_match, spotify_track, track_index, table_index, valid_candidates, download_index)
|
|
return
|
|
|
|
self.on_parallel_track_failed(download_index, f"No valid results for query: {query}")
|
|
|
|
def validate_slskd_result_with_spotify_parallel(self, slskd_result, original_spotify_track, track_index, table_index, valid_candidates, download_index):
|
|
"""Parallel version of Spotify validation - SIMPLIFIED for speed"""
|
|
print(f"🎯 Starting parallel validation for download {download_index + 1}")
|
|
|
|
# Since we already found a valid candidate with good confidence,
|
|
# we can skip complex validation and proceed directly with download
|
|
# The track matching was already done in get_valid_candidates()
|
|
|
|
# Create a simplified Spotify metadata object from the original track
|
|
spotify_metadata = original_spotify_track # Use the original Spotify track info
|
|
|
|
print(f"✅ Parallel validation passed for download {download_index + 1}: Direct match from search results")
|
|
|
|
# Proceed directly to download
|
|
self.on_predownload_validation_completed_parallel(
|
|
True, "Valid match from search results", slskd_result, original_spotify_track,
|
|
track_index, table_index, valid_candidates, download_index, spotify_metadata
|
|
)
|
|
|
|
def on_predownload_validation_completed_parallel(self, is_valid, reason, slskd_result, original_track, track_index, table_index, valid_candidates, download_index, spotify_metadata):
|
|
"""Handle parallel validation completion and start download"""
|
|
if is_valid and spotify_metadata:
|
|
print(f"✅ Parallel validation passed for download {download_index + 1}: {reason}")
|
|
|
|
# Start the download using existing infrastructure
|
|
self.start_validated_download_parallel(slskd_result, spotify_metadata, track_index, table_index, download_index)
|
|
else:
|
|
print(f"❌ Parallel validation failed for download {download_index + 1}: {reason}")
|
|
self.on_parallel_track_failed(download_index, f"Validation failed: {reason}")
|
|
|
|
def start_validated_download_parallel(self, slskd_result, spotify_metadata, track_index, table_index, download_index):
|
|
"""Start download with validated metadata - parallel version"""
|
|
# Track the source being used to avoid retrying it
|
|
if hasattr(self, 'parallel_search_tracking') and download_index in self.parallel_search_tracking:
|
|
source_key = f"{getattr(slskd_result, 'username', getattr(slskd_result, 'user', 'unknown'))}_{getattr(slskd_result, 'filename', 'unknown')}"
|
|
self.parallel_search_tracking[download_index]['used_sources'].add(source_key)
|
|
print(f"🔒 Marked source as used: {source_key}")
|
|
|
|
# Create spotify-based result for infrastructure download
|
|
spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata)
|
|
|
|
# Update table to show downloading status
|
|
if table_index is not None and table_index < self.track_table.rowCount():
|
|
downloading_item = QTableWidgetItem("⏬ Downloading")
|
|
downloading_item.setFlags(downloading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloading_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, downloading_item)
|
|
|
|
# Log the download start
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append(f"🎵 Downloading (parallel #{download_index + 1}): {spotify_based_result.filename}")
|
|
|
|
# Start download via infrastructure - but with parallel completion handling
|
|
download_id = 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,
|
|
'image_url': None
|
|
})()
|
|
|
|
download_item = self.downloads_page._start_download_with_artist(spotify_based_result, artist)
|
|
|
|
if download_item:
|
|
print(f"✅ Successfully queued parallel download {download_index + 1}")
|
|
|
|
# --- THIS IS THE NEW PART ---
|
|
# Add this download to our active list for status monitoring
|
|
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, # The result object for matching
|
|
})
|
|
# --- END OF NEW PART ---
|
|
|
|
return download_item
|
|
else:
|
|
self.on_parallel_track_failed(download_index, "Failed to start download")
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error starting parallel download {download_index + 1}: {str(e)}")
|
|
self.on_parallel_track_failed(download_index, str(e))
|
|
return None
|
|
|
|
def poll_all_download_statuses(self):
|
|
"""
|
|
Starts the background worker to process download statuses without blocking the UI.
|
|
This is called by the new QTimer.
|
|
"""
|
|
if self._is_status_update_running or not self.active_downloads:
|
|
return
|
|
|
|
self._is_status_update_running = True
|
|
|
|
items_to_check = []
|
|
for download_info in self.active_downloads[:]: # Iterate over a copy
|
|
items_to_check.append({
|
|
'widget_id': download_info['download_index'],
|
|
'download_id': download_info['download_id'],
|
|
'file_path': download_info['slskd_result'].filename,
|
|
'status': 'downloading',
|
|
})
|
|
|
|
if not items_to_check:
|
|
self._is_status_update_running = False
|
|
return
|
|
|
|
worker = SyncStatusProcessingWorker(
|
|
soulseek_client=self.parent_page.soulseek_client,
|
|
download_items_data=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):
|
|
"""
|
|
This runs on the main thread and applies status updates from the background worker.
|
|
This is where the retry logic is triggered. Now includes a queue timeout.
|
|
"""
|
|
import time
|
|
QUEUE_TIMEOUT_SECONDS = 90 # 90 seconds
|
|
|
|
for result in results:
|
|
download_index = result['widget_id']
|
|
new_status = result['status']
|
|
|
|
download_info = next((d for d in self.active_downloads if d['download_index'] == download_index), None)
|
|
if not download_info:
|
|
continue
|
|
|
|
if result.get('transfer_id'):
|
|
download_info['download_id'] = result['transfer_id']
|
|
|
|
# --- NEW QUEUE TIMEOUT LOGIC ---
|
|
is_stuck_in_queue = False
|
|
if new_status == 'queued':
|
|
if 'queued_start_time' not in download_info:
|
|
download_info['queued_start_time'] = time.time()
|
|
|
|
time_in_queue = time.time() - download_info['queued_start_time']
|
|
if time_in_queue > QUEUE_TIMEOUT_SECONDS:
|
|
is_stuck_in_queue = True
|
|
print(f"⏰ Download {download_index} is stuck in queue for {time_in_queue:.0f}s. Failing.")
|
|
else:
|
|
# Reset timer if it's no longer queued
|
|
if 'queued_start_time' in download_info:
|
|
del download_info['queued_start_time']
|
|
# --- END OF NEW LOGIC ---
|
|
|
|
if new_status in ['failed', 'cancelled'] or is_stuck_in_queue:
|
|
failure_reason = "Stuck in queue" if is_stuck_in_queue else f"Download {new_status}"
|
|
print(f"Track {download_index} failed/cancelled. Reason: {failure_reason}. Triggering retry...")
|
|
self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem("🔄 Retrying..."))
|
|
|
|
self.active_downloads.remove(download_info)
|
|
self.retry_parallel_download_with_fallback(download_index, failure_reason)
|
|
|
|
elif new_status == 'completed':
|
|
print(f"Track {download_index} completed successfully.")
|
|
self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem("✅ Downloaded"))
|
|
|
|
self.active_downloads.remove(download_info)
|
|
self.on_parallel_track_completed(download_index, success=True)
|
|
|
|
elif new_status == 'downloading':
|
|
progress = result.get('progress', 0)
|
|
self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem(f"⏬ Downloading ({progress}%)"))
|
|
|
|
elif new_status == 'queued':
|
|
self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem("... Queued"))
|
|
|
|
self._is_status_update_running = False
|
|
|
|
def check_transfer_folder_for_parallel_download(self, download_index):
|
|
"""Check if a parallel download completed and exists in Transfer folder"""
|
|
try:
|
|
# Get track info for this download
|
|
if not (hasattr(self, 'parallel_search_tracking') and
|
|
download_index in self.parallel_search_tracking):
|
|
print(f"❌ No tracking info for parallel download {download_index + 1}")
|
|
return False
|
|
|
|
track_info = self.parallel_search_tracking[download_index]
|
|
spotify_track = track_info['spotify_track']
|
|
|
|
# Get expected Transfer folder path
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
|
track_name = spotify_track.name
|
|
|
|
# Clean names for file system
|
|
clean_artist = self.sanitize_filename(artist_name)
|
|
clean_track = self.sanitize_filename(track_name)
|
|
|
|
# Check multiple possible folder structures that matched downloads might use
|
|
possible_paths = [
|
|
# Singles: Transfer/ARTIST/ARTIST - TRACK/
|
|
f"Transfer/{clean_artist}/{clean_artist} - {clean_track}",
|
|
# Albums: Transfer/ARTIST/ARTIST - ALBUM/
|
|
f"Transfer/{clean_artist}/{clean_artist} - {getattr(spotify_track, 'album', clean_track)}",
|
|
# Fallback: Transfer/ARTIST/
|
|
f"Transfer/{clean_artist}"
|
|
]
|
|
|
|
import os
|
|
import glob
|
|
|
|
for folder_path in possible_paths:
|
|
if os.path.exists(folder_path):
|
|
# Look for audio files in this folder
|
|
audio_extensions = ["*.flac", "*.mp3", "*.wav", "*.m4a", "*.ogg"]
|
|
audio_files = []
|
|
|
|
for ext in audio_extensions:
|
|
audio_files.extend(glob.glob(os.path.join(folder_path, ext)))
|
|
# Also check subdirectories one level deep
|
|
audio_files.extend(glob.glob(os.path.join(folder_path, "*", ext)))
|
|
|
|
if audio_files:
|
|
print(f"🎵 Found {len(audio_files)} audio file(s) in {folder_path}")
|
|
return True
|
|
|
|
print(f"🔍 No audio files found in Transfer folders for: {clean_artist} - {clean_track}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error checking Transfer folder for parallel download {download_index + 1}: {str(e)}")
|
|
return False
|
|
|
|
def retry_parallel_download_with_fallback(self, download_index, reason):
|
|
"""
|
|
Retries a failed download by selecting the next-best candidate from the
|
|
original cached search results.
|
|
"""
|
|
try:
|
|
print(f"🔄 Retrying parallel download {download_index + 1} from cached results - reason: {reason}")
|
|
|
|
if not (hasattr(self, 'parallel_search_tracking') and
|
|
download_index in self.parallel_search_tracking):
|
|
print(f"❌ No tracking info for retry of download {download_index + 1}")
|
|
self.on_parallel_track_failed(download_index, f"Retry failed: No tracking info")
|
|
return
|
|
|
|
track_info = self.parallel_search_tracking[download_index]
|
|
candidates = track_info.get('candidates', [])
|
|
used_sources = track_info.get('used_sources', set())
|
|
|
|
# Find the next unused candidate
|
|
next_candidate = None
|
|
for candidate in candidates:
|
|
source_key = f"{getattr(candidate, 'username', getattr(candidate, 'user', 'unknown'))}_{getattr(candidate, 'filename', 'unknown')}"
|
|
if source_key not in used_sources:
|
|
next_candidate = candidate
|
|
break
|
|
|
|
if not next_candidate:
|
|
print(f"❌ No alternative sources left in cache for download {download_index + 1}")
|
|
self.on_parallel_track_failed(download_index, "No alternative sources in cache")
|
|
return
|
|
|
|
print(f"🎯 Found next candidate from cache: {next_candidate.filename}")
|
|
|
|
track_index = track_info['track_index']
|
|
table_index = track_info['table_index']
|
|
spotify_track = track_info['spotify_track']
|
|
|
|
# Update UI to show retrying status
|
|
retry_count = len(used_sources)
|
|
if table_index is not None and table_index < self.track_table.rowCount():
|
|
retrying_item = QTableWidgetItem(f"🔄 Retry {retry_count}")
|
|
retrying_item.setFlags(retrying_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
retrying_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, retrying_item)
|
|
|
|
# Kick off the download process with the new candidate
|
|
self.on_predownload_validation_completed_parallel(
|
|
is_valid=True,
|
|
reason="Retrying with next best cached candidate",
|
|
slskd_result=next_candidate,
|
|
original_track=spotify_track,
|
|
track_index=track_index,
|
|
table_index=table_index,
|
|
valid_candidates=candidates,
|
|
download_index=download_index,
|
|
spotify_metadata=spotify_track
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error during retry from cache for download {download_index + 1}: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
self.on_parallel_track_failed(download_index, f"Retry from cache failed: {str(e)}")
|
|
|
|
def handle_retry_search_results(self, download_index, track_index, table_index, results, track_info):
|
|
"""Handle retry search results and start best alternative download"""
|
|
try:
|
|
print(f"🎯 Processing {len(results)} retry search results for download {download_index + 1}")
|
|
|
|
if not results:
|
|
self.handle_retry_search_failed(download_index, track_index, table_index, "No results found", track_info)
|
|
return
|
|
|
|
# Score and select best alternative (avoid previously used sources)
|
|
best_result = self.select_best_retry_result(results, track_info)
|
|
|
|
if not best_result:
|
|
self.handle_retry_search_failed(download_index, track_index, table_index, "No suitable alternatives", track_info)
|
|
return
|
|
|
|
# Mark this source as used
|
|
source_key = f"{getattr(best_result, 'username', getattr(best_result, 'user', 'unknown'))}_{getattr(best_result, 'filename', 'unknown')}"
|
|
track_info.setdefault('used_sources', set()).add(source_key)
|
|
|
|
print(f"🚀 Starting retry download with: {getattr(best_result, 'filename', 'unknown')} from {getattr(best_result, 'username', getattr(best_result, 'user', 'unknown'))}")
|
|
|
|
# Update UI to show downloading retry
|
|
if table_index is not None and table_index < self.track_table.rowCount():
|
|
downloading_item = QTableWidgetItem(f"⏬ Retry {track_info['retry_count']}")
|
|
downloading_item.setFlags(downloading_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloading_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(table_index, 4, downloading_item)
|
|
|
|
# Start the retry download using validation process (like original)
|
|
# We need to validate the retry result against Spotify first
|
|
original_track = track_info['spotify_track']
|
|
self.validate_slskd_result_with_spotify_parallel(best_result, original_track, track_index, table_index, download_index)
|
|
|
|
print(f"✅ Retry validation started for download {download_index + 1}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error handling retry search results: {str(e)}")
|
|
self.handle_retry_search_failed(download_index, track_index, table_index, str(e), track_info)
|
|
|
|
def handle_retry_search_failed(self, download_index, track_index, table_index, error, track_info):
|
|
"""Handle failed retry search - mark download as permanently failed"""
|
|
print(f"❌ Retry search failed for download {download_index + 1}: {error}")
|
|
|
|
# If we've exhausted retries, mark as permanently failed
|
|
if track_info.get('retry_count', 0) >= 2:
|
|
print(f"💀 Permanently failing download {download_index + 1} after {track_info.get('retry_count', 0)} retries")
|
|
self.on_parallel_track_failed(download_index, f"All retries exhausted: {error}")
|
|
else:
|
|
# Try one more retry with even more relaxed criteria
|
|
self.retry_parallel_download_with_fallback(download_index, f"Retry search failed: {error}")
|
|
|
|
def select_best_retry_result(self, results, track_info):
|
|
"""Select best result from retry search, avoiding used sources and preferring quality"""
|
|
try:
|
|
used_sources = track_info.get('used_sources', set())
|
|
spotify_track = track_info['spotify_track']
|
|
|
|
# Score each result
|
|
scored_results = []
|
|
|
|
for result in results:
|
|
source_key = f"{getattr(result, 'username', getattr(result, 'user', 'unknown'))}_{getattr(result, 'filename', 'unknown')}"
|
|
|
|
# Skip if we already tried this source
|
|
if source_key in used_sources:
|
|
continue
|
|
|
|
score = self.calculate_retry_result_score(result, spotify_track, track_info['retry_count'])
|
|
if score > 0:
|
|
scored_results.append((score, result))
|
|
|
|
if not scored_results:
|
|
return None
|
|
|
|
# Sort by score (highest first) and return best
|
|
scored_results.sort(key=lambda x: x[0], reverse=True)
|
|
best_score, best_result = scored_results[0]
|
|
|
|
print(f"📊 Selected retry result (score: {best_score}): {getattr(best_result, 'filename', 'unknown')}")
|
|
return best_result
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error selecting best retry result: {str(e)}")
|
|
return results[0] if results else None
|
|
|
|
def calculate_retry_result_score(self, result, spotify_track, retry_count):
|
|
"""Calculate score for retry result with quality fallback preferences"""
|
|
try:
|
|
score = 0
|
|
filename = getattr(result, 'filename', '').lower()
|
|
|
|
# Basic track name matching (required)
|
|
track_name_lower = spotify_track.name.lower()
|
|
if track_name_lower in filename:
|
|
score += 150
|
|
else:
|
|
return 0 # Must contain track name
|
|
|
|
# Artist matching (helpful but not required for retries)
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
if artist_name.lower() in filename:
|
|
score += 50
|
|
|
|
# Quality scoring with fallback preferences
|
|
if retry_count == 1:
|
|
# First retry: still prefer FLAC but accept MP3
|
|
if 'flac' in filename:
|
|
score += 40
|
|
elif 'mp3' in filename:
|
|
score += 25
|
|
elif any(fmt in filename for fmt in ['wav', 'm4a', 'ogg']):
|
|
score += 30
|
|
else:
|
|
# Second retry: be more lenient, accept any decent quality
|
|
if any(fmt in filename for fmt in ['flac', 'mp3', 'wav', 'm4a']):
|
|
score += 20
|
|
|
|
# Bitrate preferences (for retries, accept lower quality)
|
|
if retry_count == 1:
|
|
if any(br in filename for br in ['320', '1411', '44100']):
|
|
score += 15
|
|
else:
|
|
if any(br in filename for br in ['320', '256', '192', '1411']):
|
|
score += 10
|
|
|
|
# Duration matching (if available)
|
|
if hasattr(result, 'duration') and hasattr(spotify_track, 'duration_ms'):
|
|
result_duration = getattr(result, 'duration', 0)
|
|
spotify_duration = spotify_track.duration_ms // 1000
|
|
if result_duration and spotify_duration:
|
|
duration_diff = abs(result_duration - spotify_duration)
|
|
if duration_diff <= 5: # Within 5 seconds
|
|
score += 25
|
|
elif duration_diff <= 15: # Within 15 seconds
|
|
score += 15
|
|
|
|
return score
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error calculating retry score: {str(e)}")
|
|
return 1 # Minimal score to keep it in consideration
|
|
|
|
def on_parallel_track_completed(self, download_index, success):
|
|
"""Handle completion of a parallel track download"""
|
|
# Mark in download tracking
|
|
if hasattr(self, 'parallel_download_tracking') and download_index in self.parallel_download_tracking:
|
|
self.parallel_download_tracking[download_index]['completed'] = True
|
|
|
|
if hasattr(self, 'parallel_search_tracking') and download_index in self.parallel_search_tracking:
|
|
track_info = self.parallel_search_tracking[download_index]
|
|
|
|
if not track_info['completed']: # Prevent double completion
|
|
track_info['completed'] = True
|
|
|
|
# Update UI
|
|
if success:
|
|
# Mark as downloaded
|
|
if track_info['table_index'] is not None:
|
|
downloaded_item = QTableWidgetItem("✅ Downloaded")
|
|
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(track_info['table_index'], 4, downloaded_item)
|
|
|
|
# Update counters
|
|
self.downloaded_tracks_count += 1
|
|
if hasattr(self, 'downloaded_count_label'):
|
|
self.downloaded_count_label.setText(str(self.downloaded_tracks_count))
|
|
|
|
self.successful_downloads += 1
|
|
|
|
# Log success
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
track = track_info['spotify_track']
|
|
artist_name = track.artists[0] if track.artists else "Unknown"
|
|
self.parent_page.log_area.append(f"✅ Downloaded (parallel #{download_index + 1}): {track.name} by {artist_name}")
|
|
else:
|
|
# Mark as failed
|
|
if track_info['table_index'] is not None:
|
|
failed_item = QTableWidgetItem("❌ Failed")
|
|
failed_item.setFlags(failed_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
failed_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(track_info['table_index'], 4, failed_item)
|
|
|
|
self.failed_downloads += 1
|
|
|
|
# Update progress counters
|
|
self.completed_downloads += 1
|
|
self.active_parallel_downloads -= 1
|
|
|
|
# Update progress bar
|
|
self.download_progress.setValue(self.completed_downloads)
|
|
|
|
# Start next batch if needed
|
|
self.start_next_batch_of_downloads(3) # Max concurrent limit
|
|
|
|
else:
|
|
print(f"⚠️ Parallel track completion called for unknown download_index: {download_index}")
|
|
|
|
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}")
|
|
|
|
if hasattr(self, 'parallel_search_tracking') and download_index in self.parallel_search_tracking:
|
|
track_info = self.parallel_search_tracking[download_index]
|
|
|
|
# --- NEW LOGIC TO TRACK PERMANENT FAILURES ---
|
|
# Add the failed track to our list for manual correction
|
|
if track_info not in self.permanently_failed_tracks:
|
|
self.permanently_failed_tracks.append(track_info)
|
|
self.update_failed_matches_button() # Update the button visibility and count
|
|
# --- END OF NEW LOGIC ---
|
|
|
|
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
|
|
|
|
# Create and show the modal
|
|
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.
|
|
"""
|
|
# The download has already been started by the manual modal.
|
|
# We just need to update our internal state.
|
|
|
|
# Find the original failed track in our list and remove it
|
|
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)
|
|
|
|
# Update the button counter
|
|
self.update_failed_matches_button()
|
|
|
|
def find_track_index(self, spotify_track):
|
|
"""Find the table row index for a given Spotify track"""
|
|
for i, playlist_track in enumerate(self.playlist.tracks):
|
|
if (playlist_track.name == spotify_track.name and
|
|
playlist_track.artists[0] == spotify_track.artists[0]):
|
|
return i
|
|
return None
|
|
|
|
def on_track_download_started(self, download_index, track_index, download_id):
|
|
"""Handle download start - set up monitoring for completion"""
|
|
print(f"⏬ Download {download_index + 1} started: {download_id}")
|
|
|
|
# Store download info for monitoring
|
|
if not hasattr(self, 'active_downloads'):
|
|
self.active_downloads = {}
|
|
self.active_downloads[download_id] = {
|
|
'download_index': download_index,
|
|
'track_index': track_index,
|
|
'status': 'downloading'
|
|
}
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area') and download_index < len(self.missing_tracks):
|
|
track = self.missing_tracks[download_index].spotify_track
|
|
track_name = track.name
|
|
artist_name = track.artists[0] if track.artists else "Unknown Artist"
|
|
self.parent_page.log_area.append(f"⏬ Started download: {track_name} by {artist_name}")
|
|
|
|
# Table already shows "⏬ Downloading" from download_next_track()
|
|
|
|
# Start monitoring this download for completion
|
|
self.start_download_monitoring(download_id, download_index, track_index)
|
|
|
|
# Increment download counter (tracking started downloads)
|
|
self.current_download += 1
|
|
|
|
# REMOVED: Old sequential download call - now using parallel system
|
|
# self.download_next_track() # Parallel system handles queueing automatically
|
|
|
|
def on_track_download_failed(self, download_index, track_index, error_message):
|
|
"""Handle failed track download"""
|
|
print(f"❌ Download {download_index + 1} failed: {error_message}")
|
|
|
|
# Update main console log
|
|
if hasattr(self.parent_page, 'log_area') and download_index < len(self.missing_tracks):
|
|
track = self.missing_tracks[download_index].spotify_track
|
|
track_name = track.name
|
|
artist_name = track.artists[0] if track.artists else "Unknown Artist"
|
|
self.parent_page.log_area.append(f"❌ Download failed: {track_name} by {artist_name} - {error_message}")
|
|
|
|
# Update table row
|
|
if track_index is not None:
|
|
failed_item = QTableWidgetItem("❌ Failed")
|
|
failed_item.setFlags(failed_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
failed_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(track_index, 4, failed_item)
|
|
|
|
# Update failure tracking
|
|
self.failed_downloads += 1
|
|
self.completed_downloads += 1 # Count as completed (failed = finished)
|
|
|
|
# Update progress based on completed downloads (success + failures)
|
|
self.download_progress.setValue(self.completed_downloads)
|
|
|
|
# Check if all downloads are complete
|
|
if self.completed_downloads >= len(self.missing_tracks):
|
|
self.on_all_downloads_complete()
|
|
|
|
def on_all_downloads_complete(self):
|
|
"""Handle completion of all downloads"""
|
|
self.download_in_progress = False
|
|
print("🎉 All downloads completed!")
|
|
|
|
# Use our tracked statistics
|
|
completed_count = self.successful_downloads
|
|
failed_count = self.failed_downloads
|
|
|
|
# Update main console log with final statistics
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
total_requested = len(self.missing_tracks)
|
|
success_rate = (completed_count / total_requested * 100) if total_requested > 0 else 0
|
|
|
|
self.parent_page.log_area.append(f"🎉 Download operation complete!")
|
|
self.parent_page.log_area.append(f"📊 Results: {completed_count}/{total_requested} successful ({success_rate:.1f}%)")
|
|
|
|
if failed_count > 0:
|
|
self.parent_page.log_area.append(f"⚠️ {failed_count} downloads failed - tracks may need manual search")
|
|
|
|
# Hide Cancel button - operations complete
|
|
self.cancel_btn.hide()
|
|
|
|
# Update playlist status indicator - operation complete
|
|
playlist_item = self.find_playlist_item()
|
|
if playlist_item:
|
|
playlist_item.hide_operation_status()
|
|
|
|
# Re-enable refresh button - downloads complete
|
|
if hasattr(self.parent_page, 'enable_refresh_button'):
|
|
self.parent_page.enable_refresh_button()
|
|
|
|
QMessageBox.information(self, "Downloads Complete",
|
|
f"Completed downloading {completed_count}/{len(self.missing_tracks)} missing tracks!")
|
|
|
|
def start_download_monitoring(self, download_id, download_index, track_index):
|
|
"""Start monitoring a download for completion using downloads.py approach"""
|
|
if not hasattr(self, 'download_timers'):
|
|
self.download_timers = []
|
|
|
|
# Create a timer to check this specific download
|
|
timer = QTimer()
|
|
timer.timeout.connect(lambda: self.check_download_status(download_id, download_index, track_index, timer))
|
|
timer.start(2000) # Check every 2 seconds
|
|
self.download_timers.append(timer)
|
|
|
|
print(f"🕐 Started monitoring download: {download_id}")
|
|
|
|
def check_download_status(self, download_id, download_index, track_index, timer):
|
|
"""Check if a specific download has completed"""
|
|
# Check for cancellation
|
|
if hasattr(self, 'cancel_requested') and self.cancel_requested:
|
|
timer.stop()
|
|
return
|
|
|
|
# Create worker to check download status (like downloads.py does)
|
|
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal
|
|
|
|
class DownloadStatusWorkerSignals(QObject):
|
|
status_checked = pyqtSignal(str, int, int, QTimer) # status, download_index, track_index, timer
|
|
check_failed = pyqtSignal(int, int, str, QTimer) # download_index, track_index, error, timer
|
|
|
|
class DownloadStatusWorker(QRunnable):
|
|
def __init__(self, soulseek_client, download_id):
|
|
super().__init__()
|
|
self.soulseek_client = soulseek_client
|
|
self.download_id = download_id
|
|
self.signals = DownloadStatusWorkerSignals()
|
|
self._stop_requested = False
|
|
|
|
def run(self):
|
|
if self._stop_requested:
|
|
return
|
|
|
|
try:
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
# Get all downloads and find our specific one
|
|
downloads = loop.run_until_complete(self.soulseek_client.get_all_downloads())
|
|
|
|
if downloads and 'transfers' in downloads:
|
|
for user_data in downloads['transfers']:
|
|
if 'directories' in user_data:
|
|
for directory in user_data['directories']:
|
|
if 'files' in directory:
|
|
for file_transfer in directory['files']:
|
|
if file_transfer.get('id') == self.download_id:
|
|
state = file_transfer.get('state', 'Unknown')
|
|
self.signals.status_checked.emit(state, download_index, track_index, timer)
|
|
loop.close()
|
|
return
|
|
|
|
# If we get here, download wasn't found - might be completed and cleaned up
|
|
self.signals.check_failed.emit(download_index, track_index, "Download not found in active transfers", timer)
|
|
loop.close()
|
|
|
|
except Exception as e:
|
|
self.signals.check_failed.emit(download_index, track_index, str(e), timer)
|
|
if 'loop' in locals():
|
|
loop.close()
|
|
|
|
# Create and start worker
|
|
worker = DownloadStatusWorker(self.parent_page.soulseek_client, download_id)
|
|
worker.signals.status_checked.connect(self.on_download_status_checked)
|
|
worker.signals.check_failed.connect(self.on_download_status_check_failed)
|
|
|
|
# CRITICAL: Track worker for cancellation
|
|
if not hasattr(self, 'active_workers'):
|
|
self.active_workers = []
|
|
self.active_workers.append(worker)
|
|
|
|
# Submit to thread pool
|
|
if hasattr(self.parent_page, 'thread_pool'):
|
|
self.parent_page.thread_pool.start(worker)
|
|
else:
|
|
thread_pool = QThreadPool()
|
|
self.fallback_pools.append(thread_pool)
|
|
thread_pool.start(worker)
|
|
|
|
def on_download_status_checked(self, state, download_index, track_index, timer):
|
|
"""Handle download status check result"""
|
|
if state == "Completed":
|
|
timer.stop()
|
|
self.on_actual_track_download_complete(download_index, track_index)
|
|
elif state in ["Cancelled", "Failed"]:
|
|
timer.stop()
|
|
self.on_track_download_failed(download_index, track_index, f"Download {state.lower()}")
|
|
# For "InProgress", "Queued", etc. - keep monitoring
|
|
|
|
def on_download_status_check_failed(self, download_index, track_index, error, timer):
|
|
"""Handle download status check failure"""
|
|
# If download not found, it might have completed and been cleaned up
|
|
# Try one more check in 5 seconds, then assume completed
|
|
print(f"⚠️ Status check failed for download {download_index + 1}: {error}")
|
|
|
|
# For now, assume it completed (downloads.py removes completed downloads)
|
|
timer.stop()
|
|
self.on_actual_track_download_complete(download_index, track_index)
|
|
|
|
def on_actual_track_download_complete(self, download_index, track_index):
|
|
"""Handle when a download is actually completed - NOW ONLY USED FOR PRE-VALIDATION SYSTEM"""
|
|
print(f"🔍 Download {download_index + 1} completed, will validate via Spotify validation system")
|
|
|
|
# Don't mark as downloaded here - let the validation system handle it
|
|
# Just update internal tracking that the download file is ready for validation
|
|
if track_index is not None and track_index < self.track_table.rowCount():
|
|
validating_item = QTableWidgetItem("🔍 Validating")
|
|
validating_item.setFlags(validating_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
validating_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(track_index, 4, validating_item)
|
|
|
|
# The actual completion tracking is now handled by on_validation_completed()
|
|
# which properly waits for Spotify validation before marking as downloaded
|
|
|
|
def setup_background_status_updates(self):
|
|
"""Set up timer-based background status updates for playlist indicator"""
|
|
from PyQt6.QtCore import QTimer
|
|
|
|
# Create a timer for background updates
|
|
if not hasattr(self, 'status_update_timer'):
|
|
self.status_update_timer = QTimer()
|
|
self.status_update_timer.timeout.connect(self.update_background_status)
|
|
self.status_update_timer.start(500) # Update every 500ms
|
|
|
|
def update_background_status(self):
|
|
"""Update playlist status in background"""
|
|
playlist_item = self.find_playlist_item()
|
|
if not playlist_item:
|
|
return
|
|
|
|
try:
|
|
# Update based on current progress state
|
|
if not self.analysis_complete:
|
|
# Still analyzing
|
|
if hasattr(self, 'analysis_progress'):
|
|
progress = self.analysis_progress.value()
|
|
total = len(self.playlist.tracks)
|
|
status_text = f"🔍 Analyzing {progress}/{total}"
|
|
playlist_item.update_operation_status(status_text)
|
|
elif self.download_in_progress:
|
|
# Downloading
|
|
if hasattr(self, 'download_progress'):
|
|
progress = self.download_progress.value()
|
|
total = len(self.missing_tracks) if hasattr(self, 'missing_tracks') else 0
|
|
status_text = f"⏬ Downloading {progress}/{total}"
|
|
playlist_item.update_operation_status(status_text)
|
|
else:
|
|
# Operations complete - stop timer and hide status
|
|
if hasattr(self, 'status_update_timer'):
|
|
self.status_update_timer.stop()
|
|
playlist_item.hide_operation_status()
|
|
|
|
# Re-enable refresh button - background operations complete
|
|
if hasattr(self.parent_page, 'enable_refresh_button'):
|
|
self.parent_page.enable_refresh_button()
|
|
|
|
except Exception as e:
|
|
print(f"Background status update error: {e}")
|
|
|
|
def update_playlist_status_indicator(self):
|
|
"""Update the playlist status indicator instead of creating a bubble"""
|
|
# Find the playlist item in the parent page
|
|
playlist_item = self.find_playlist_item()
|
|
if playlist_item:
|
|
# Determine current operation status
|
|
if not self.analysis_complete:
|
|
# Still analyzing
|
|
progress = self.analysis_progress.value()
|
|
total = len(self.playlist.tracks)
|
|
status_text = f"🔍 Analyzing {progress}/{total}"
|
|
elif self.download_in_progress:
|
|
# Downloading
|
|
progress = self.download_progress.value()
|
|
total = len(self.missing_tracks) if hasattr(self, 'missing_tracks') else 0
|
|
status_text = f"⏬ Downloading {progress}/{total}"
|
|
else:
|
|
status_text = "✅ Complete"
|
|
|
|
playlist_item.show_operation_status(status_text)
|
|
|
|
def find_playlist_item(self):
|
|
"""Find the PlaylistItem widget for this playlist"""
|
|
if not hasattr(self.parent_page, 'current_playlists'):
|
|
return None
|
|
|
|
# Look through the parent page's playlist items
|
|
for i in range(self.parent_page.playlist_layout.count()):
|
|
item = self.parent_page.playlist_layout.itemAt(i)
|
|
if item and item.widget() and isinstance(item.widget(), PlaylistItem):
|
|
playlist_item = item.widget()
|
|
if playlist_item.playlist and playlist_item.playlist.id == self.playlist.id:
|
|
return playlist_item
|
|
return None
|
|
|
|
def simulate_downloads(self):
|
|
"""Simulate download process (placeholder for real implementation)"""
|
|
from PyQt6.QtCore import QTimer
|
|
|
|
self.current_download = 0
|
|
self.download_timer = QTimer()
|
|
self.download_timer.timeout.connect(self.simulate_next_download)
|
|
self.download_timer.start(1500) # Simulate 1.5 seconds per download
|
|
|
|
def simulate_next_download(self):
|
|
"""Simulate next download completion"""
|
|
if self.current_download < len(self.missing_tracks):
|
|
# Find the track in the table and update its status
|
|
missing_result = self.missing_tracks[self.current_download]
|
|
|
|
# Find track index in original playlist
|
|
track_index = None
|
|
for i, track in enumerate(self.playlist.tracks):
|
|
if track.id == missing_result.spotify_track.id:
|
|
track_index = i
|
|
break
|
|
|
|
if track_index is not None:
|
|
# Update Downloaded column
|
|
downloaded_item = QTableWidgetItem("✅ Complete")
|
|
downloaded_item.setFlags(downloaded_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
downloaded_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.track_table.setItem(track_index, 4, downloaded_item)
|
|
|
|
# Update progress
|
|
self.current_download += 1
|
|
self.download_progress.setValue(self.current_download)
|
|
|
|
else:
|
|
# All downloads complete
|
|
self.download_timer.stop()
|
|
self.begin_search_btn.setText("Downloads Complete")
|
|
QMessageBox.information(self, "Downloads Complete",
|
|
f"Successfully downloaded {len(self.missing_tracks)} missing tracks!")
|
|
|
|
def on_cancel_clicked(self):
|
|
"""Handle Cancel button - cancels operations and closes modal"""
|
|
self.cancel_operations()
|
|
self.reject() # Close modal with cancel result
|
|
|
|
def on_close_clicked(self):
|
|
"""Handle Close button - closes modal without canceling operations"""
|
|
# If operations are in progress, set up background status updates
|
|
if self.download_in_progress or not self.analysis_complete:
|
|
self.setup_background_status_updates()
|
|
|
|
# Close modal without canceling operations
|
|
self.reject()
|
|
|
|
def cancel_operations(self):
|
|
"""Cancel any ongoing operations"""
|
|
print("🛑 Cancelling all operations...")
|
|
|
|
# Set cancellation flag to stop ongoing processes
|
|
self.cancel_requested = True
|
|
|
|
# Cancel all active workers
|
|
if hasattr(self, 'active_workers'):
|
|
print(f"🛑 Stopping {len(self.active_workers)} active workers...")
|
|
for worker in self.active_workers:
|
|
if hasattr(worker, 'cancel'):
|
|
worker.cancel()
|
|
# Set stop flag for our custom workers
|
|
if hasattr(worker, '_stop_requested'):
|
|
worker._stop_requested = True
|
|
print(f" 🛑 Set stop flag for worker: {type(worker).__name__}")
|
|
self.active_workers.clear()
|
|
|
|
# Terminate any fallback thread pools
|
|
if hasattr(self, 'fallback_pools'):
|
|
print(f"🛑 Waiting for {len(self.fallback_pools)} thread pools to finish...")
|
|
for pool in self.fallback_pools:
|
|
if pool:
|
|
pool.waitForDone(5000) # Wait up to 5 seconds
|
|
self.fallback_pools.clear()
|
|
|
|
# Stop all download monitoring timers
|
|
if hasattr(self, 'download_timers'):
|
|
print(f"🛑 Stopping {len(self.download_timers)} download timers...")
|
|
for timer in self.download_timers:
|
|
if timer.isActive():
|
|
timer.stop()
|
|
self.download_timers.clear()
|
|
|
|
# Stop analysis/download timer
|
|
if hasattr(self, 'download_timer') and self.download_timer:
|
|
if self.download_timer.isActive():
|
|
print("🛑 Stopping main download timer...")
|
|
self.download_timer.stop()
|
|
|
|
# Stop status update timer
|
|
if hasattr(self, 'status_update_timer') and self.status_update_timer:
|
|
if self.status_update_timer.isActive():
|
|
print("🛑 Stopping status update timer...")
|
|
self.status_update_timer.stop()
|
|
|
|
# Cancel any pending downloads via SoulseekClient
|
|
if hasattr(self, 'track_download_items'):
|
|
for download_item, (track_index, table_index) in self.track_download_items.items():
|
|
try:
|
|
if hasattr(download_item, 'download_id') and download_item.download_id:
|
|
# Cancel download via SoulseekClient
|
|
print(f"🛑 Cancelling download: {download_item.download_id}")
|
|
|
|
# Create async task to cancel download
|
|
import asyncio
|
|
try:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
cancel_result = loop.run_until_complete(
|
|
self.parent_page.soulseek_client.cancel_download(download_item.download_id)
|
|
)
|
|
loop.close()
|
|
print(f" ✅ Download {download_item.download_id} cancellation: {cancel_result}")
|
|
except Exception as async_error:
|
|
print(f" ❌ Async cancel error: {async_error}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error cancelling download: {e}")
|
|
|
|
# Restore playlist button to normal state
|
|
if hasattr(self, 'playlist_item') and self.playlist_item:
|
|
self.playlist_item.hide_operation_status()
|
|
|
|
# Update main console
|
|
if hasattr(self.parent_page, 'log_area'):
|
|
self.parent_page.log_area.append("🛑 All download operations cancelled by user")
|
|
|
|
print("✅ Cancellation complete - all operations stopped")
|
|
|
|
# Reset all operation states
|
|
self.download_in_progress = False
|
|
self.analysis_complete = False
|
|
self.current_search_index = 0
|
|
self.successful_downloads = 0
|
|
self.completed_downloads = 0
|
|
|
|
# Clear download tracking
|
|
if hasattr(self, 'track_download_items'):
|
|
self.track_download_items.clear()
|
|
|
|
# Reset button states - hide Cancel, show Begin Search
|
|
self.cancel_btn.hide()
|
|
self.begin_search_btn.show()
|
|
self.begin_search_btn.setEnabled(True)
|
|
self.begin_search_btn.setText("Begin Search")
|
|
|
|
# Hide progress bars
|
|
self.analysis_progress.setVisible(False)
|
|
self.download_progress.setVisible(False)
|
|
|
|
# Reset state flags
|
|
self.analysis_complete = False
|
|
self.download_in_progress = False
|
|
self.cancel_requested = False # Reset for next time
|
|
|
|
# Reset search state
|
|
if hasattr(self, 'current_search_index'):
|
|
self.current_search_index = 0
|
|
if hasattr(self, 'track_search_results'):
|
|
self.track_search_results.clear()
|
|
if hasattr(self, 'track_candidate_index'):
|
|
self.track_candidate_index.clear()
|
|
|
|
# Reset parallel tracking
|
|
if hasattr(self, 'parallel_download_tracking'):
|
|
self.parallel_download_tracking.clear()
|
|
if hasattr(self, 'parallel_search_tracking'):
|
|
self.parallel_search_tracking.clear()
|
|
|
|
# Reset playlist status indicator
|
|
playlist_item = self.find_playlist_item()
|
|
if playlist_item:
|
|
playlist_item.hide_operation_status()
|
|
|
|
# Re-enable refresh button - operations cancelled
|
|
if hasattr(self.parent_page, 'enable_refresh_button'):
|
|
self.parent_page.enable_refresh_button()
|
|
|
|
print("🛑 All operations cancelled successfully")
|
|
|
|
def closeEvent(self, event):
|
|
"""Handle modal close event"""
|
|
print("🔄 DownloadMissingTracksModal closing...")
|
|
|
|
# Clean up any timers first to prevent reentrant modal session errors
|
|
if hasattr(self, 'download_timers'):
|
|
for timer in self.download_timers:
|
|
try:
|
|
if timer.isActive():
|
|
timer.stop()
|
|
except Exception:
|
|
pass
|
|
self.download_timers.clear()
|
|
|
|
# If operations are still in progress when closing, set up background updates
|
|
if (self.download_in_progress or not self.analysis_complete) and not hasattr(self, 'background_timer_started'):
|
|
self.setup_background_status_updates()
|
|
self.background_timer_started = True
|
|
else:
|
|
# If no operations in progress, re-enable refresh button
|
|
if hasattr(self.parent_page, 'enable_refresh_button'):
|
|
self.parent_page.enable_refresh_button()
|
|
|
|
# Clean up workers
|
|
try:
|
|
self.cleanup_workers()
|
|
except Exception as e:
|
|
print(f"⚠️ Error cleaning up workers: {e}")
|
|
|
|
# Only cancel if user explicitly clicked Cancel
|
|
# For Close button or X button, preserve operations
|
|
event.accept()
|
|
print("✅ DownloadMissingTracksModal closed")
|
|
|
|
def cleanup_workers(self):
|
|
"""Clean up all active workers and thread pools"""
|
|
# Cancel active workers first
|
|
for worker in self.active_workers:
|
|
try:
|
|
if hasattr(worker, 'cancel'):
|
|
worker.cancel()
|
|
elif hasattr(worker, '_stop_requested'):
|
|
worker._stop_requested = True
|
|
except (RuntimeError, AttributeError):
|
|
pass
|
|
|
|
# Clean up fallback thread pools with timeout
|
|
for pool in self.fallback_pools:
|
|
try:
|
|
pool.clear() # Cancel pending workers
|
|
if not pool.waitForDone(1000): # Wait 1 second max
|
|
pool.clear() # Force termination
|
|
except (RuntimeError, AttributeError):
|
|
pass
|
|
|
|
# Clear tracking lists
|
|
self.active_workers.clear()
|
|
self.fallback_pools.clear() |