better
This commit is contained in:
parent
87f52af1e2
commit
425c2402af
2 changed files with 294 additions and 7 deletions
|
|
@ -202,12 +202,15 @@ Playlist Track → Plex Check → (Missing) → Soulseek Search → Quality Filt
|
||||||
- ✅ Add basic progress indication for user feedback
|
- ✅ Add basic progress indication for user feedback
|
||||||
- ✅ Added graceful Plex connection handling
|
- ✅ Added graceful Plex connection handling
|
||||||
|
|
||||||
2. **🔄 IN PROGRESS - Plex Integration for Track Existence Checking**
|
2. **✅ COMPLETED - Plex Integration for Track Existence Checking**
|
||||||
- Implement intelligent track matching against Plex library
|
- ✅ Implement intelligent track matching against Plex library
|
||||||
- Use confidence scoring (≥0.8) to determine existing tracks
|
- ✅ Use confidence scoring (≥0.8) to determine existing tracks
|
||||||
- Handle Plex connection failures gracefully (download all tracks)
|
- ✅ Handle Plex connection failures gracefully (download all tracks)
|
||||||
|
- ✅ Background worker with progress tracking
|
||||||
|
- ✅ String normalization and similarity scoring
|
||||||
|
- ✅ Duration matching with 10% tolerance
|
||||||
|
|
||||||
3. **⏳ PENDING - Soulseek Search Integration**
|
3. **🔄 IN PROGRESS - Soulseek Search Integration**
|
||||||
- Implement per-track search strategy (track name → artist + track name)
|
- Implement per-track search strategy (track name → artist + track name)
|
||||||
- Leverage existing search filtering and quality selection
|
- Leverage existing search filtering and quality selection
|
||||||
- Use async operations for performance
|
- Use async operations for performance
|
||||||
|
|
|
||||||
288
ui/pages/sync.py
288
ui/pages/sync.py
|
|
@ -5,6 +5,156 @@ from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
QTableWidget, QTableWidgetItem, QHeaderView)
|
QTableWidget, QTableWidgetItem, QHeaderView)
|
||||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject
|
||||||
from PyQt6.QtGui import QFont
|
from PyQt6.QtGui import QFont
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackAnalysisResult:
|
||||||
|
"""Result of analyzing a track for Plex existence"""
|
||||||
|
spotify_track: object # Spotify track object
|
||||||
|
exists_in_plex: bool
|
||||||
|
plex_match: Optional[object] = None # Plex track if found
|
||||||
|
confidence: float = 0.0
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
class PlaylistTrackAnalysisWorkerSignals(QObject):
|
||||||
|
"""Signals for playlist track analysis worker"""
|
||||||
|
analysis_started = pyqtSignal(int) # total_tracks
|
||||||
|
track_analyzed = pyqtSignal(int, object) # track_index, TrackAnalysisResult
|
||||||
|
analysis_completed = pyqtSignal(list) # List[TrackAnalysisResult]
|
||||||
|
analysis_failed = pyqtSignal(str) # error_message
|
||||||
|
|
||||||
|
class PlaylistTrackAnalysisWorker(QRunnable):
|
||||||
|
"""Background worker to analyze playlist tracks against Plex library"""
|
||||||
|
|
||||||
|
def __init__(self, playlist_tracks, plex_client):
|
||||||
|
super().__init__()
|
||||||
|
self.playlist_tracks = playlist_tracks
|
||||||
|
self.plex_client = plex_client
|
||||||
|
self.signals = PlaylistTrackAnalysisWorkerSignals()
|
||||||
|
self._cancelled = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
"""Cancel the analysis operation"""
|
||||||
|
self._cancelled = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Analyze each track in the playlist"""
|
||||||
|
try:
|
||||||
|
if self._cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.signals.analysis_started.emit(len(self.playlist_tracks))
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Check if Plex is connected
|
||||||
|
plex_connected = False
|
||||||
|
try:
|
||||||
|
if self.plex_client:
|
||||||
|
plex_connected = self.plex_client.is_connected()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Plex connection check failed: {e}")
|
||||||
|
plex_connected = False
|
||||||
|
|
||||||
|
for i, track in enumerate(self.playlist_tracks):
|
||||||
|
if self._cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = TrackAnalysisResult(
|
||||||
|
spotify_track=track,
|
||||||
|
exists_in_plex=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if plex_connected:
|
||||||
|
# Check if track exists in Plex
|
||||||
|
try:
|
||||||
|
plex_match, confidence = self._check_track_in_plex(track)
|
||||||
|
if plex_match and confidence >= 0.8: # High confidence threshold
|
||||||
|
result.exists_in_plex = True
|
||||||
|
result.plex_match = plex_match
|
||||||
|
result.confidence = confidence
|
||||||
|
except Exception as e:
|
||||||
|
result.error_message = f"Plex check failed: {str(e)}"
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
self.signals.track_analyzed.emit(i + 1, result)
|
||||||
|
|
||||||
|
if not self._cancelled:
|
||||||
|
self.signals.analysis_completed.emit(results)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not self._cancelled:
|
||||||
|
self.signals.analysis_failed.emit(str(e))
|
||||||
|
|
||||||
|
def _check_track_in_plex(self, spotify_track):
|
||||||
|
"""Check if a Spotify track exists in Plex with confidence scoring"""
|
||||||
|
try:
|
||||||
|
# Search Plex for similar tracks
|
||||||
|
# Use first artist for search
|
||||||
|
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
||||||
|
search_query = f"{artist_name} {spotify_track.name}".strip()
|
||||||
|
|
||||||
|
# Get potential matches from Plex
|
||||||
|
plex_tracks = self.plex_client.search_tracks(search_query, limit=10)
|
||||||
|
|
||||||
|
if not plex_tracks:
|
||||||
|
return None, 0.0
|
||||||
|
|
||||||
|
# Find best match using confidence scoring
|
||||||
|
best_match = None
|
||||||
|
best_confidence = 0.0
|
||||||
|
|
||||||
|
for plex_track in plex_tracks:
|
||||||
|
confidence = self._calculate_track_confidence(spotify_track, plex_track)
|
||||||
|
if confidence > best_confidence:
|
||||||
|
best_confidence = confidence
|
||||||
|
best_match = plex_track
|
||||||
|
|
||||||
|
return best_match, best_confidence
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error checking track in Plex: {e}")
|
||||||
|
return None, 0.0
|
||||||
|
|
||||||
|
def _calculate_track_confidence(self, spotify_track, plex_track):
|
||||||
|
"""Calculate confidence score between Spotify and Plex tracks"""
|
||||||
|
try:
|
||||||
|
# Basic string similarity for now (can be enhanced with existing matching engine)
|
||||||
|
import re
|
||||||
|
|
||||||
|
def normalize_string(s):
|
||||||
|
return re.sub(r'[^a-zA-Z0-9\s]', '', s.lower()).strip()
|
||||||
|
|
||||||
|
# Normalize track titles
|
||||||
|
spotify_title = normalize_string(spotify_track.name)
|
||||||
|
plex_title = normalize_string(plex_track.title)
|
||||||
|
|
||||||
|
# Normalize artist names
|
||||||
|
spotify_artist = normalize_string(spotify_track.artists[0]) if spotify_track.artists else ""
|
||||||
|
plex_artist = normalize_string(plex_track.artist)
|
||||||
|
|
||||||
|
# Simple similarity scoring
|
||||||
|
title_similarity = 1.0 if spotify_title == plex_title else 0.0
|
||||||
|
artist_similarity = 1.0 if spotify_artist == plex_artist else 0.0
|
||||||
|
|
||||||
|
# Weight title more heavily
|
||||||
|
confidence = (title_similarity * 0.7) + (artist_similarity * 0.3)
|
||||||
|
|
||||||
|
# Duration check (allow 10% variance)
|
||||||
|
if hasattr(spotify_track, 'duration_ms') and hasattr(plex_track, 'duration'):
|
||||||
|
spotify_duration = spotify_track.duration_ms / 1000
|
||||||
|
plex_duration = plex_track.duration / 1000 if plex_track.duration else 0
|
||||||
|
|
||||||
|
if plex_duration > 0:
|
||||||
|
duration_diff = abs(spotify_duration - plex_duration) / max(spotify_duration, plex_duration)
|
||||||
|
if duration_diff <= 0.1: # Within 10%
|
||||||
|
confidence += 0.1 # Bonus for duration match
|
||||||
|
|
||||||
|
return min(confidence, 1.0) # Cap at 1.0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error calculating track confidence: {e}")
|
||||||
|
return 0.0
|
||||||
|
|
||||||
class PlaylistLoaderThread(QThread):
|
class PlaylistLoaderThread(QThread):
|
||||||
playlist_loaded = pyqtSignal(object) # Single playlist
|
playlist_loaded = pyqtSignal(object) # Single playlist
|
||||||
|
|
@ -125,7 +275,7 @@ class PlaylistDetailsModal(QDialog):
|
||||||
for worker in self.active_workers:
|
for worker in self.active_workers:
|
||||||
try:
|
try:
|
||||||
if hasattr(worker, 'signals'):
|
if hasattr(worker, 'signals'):
|
||||||
# Only disconnect signals that are actually connected to this modal
|
# Disconnect track loading worker signals
|
||||||
try:
|
try:
|
||||||
worker.signals.tracks_loaded.disconnect(self.on_tracks_loaded)
|
worker.signals.tracks_loaded.disconnect(self.on_tracks_loaded)
|
||||||
except (RuntimeError, TypeError):
|
except (RuntimeError, TypeError):
|
||||||
|
|
@ -134,7 +284,24 @@ class PlaylistDetailsModal(QDialog):
|
||||||
worker.signals.loading_failed.disconnect(self.on_tracks_loading_failed)
|
worker.signals.loading_failed.disconnect(self.on_tracks_loading_failed)
|
||||||
except (RuntimeError, TypeError):
|
except (RuntimeError, TypeError):
|
||||||
pass
|
pass
|
||||||
# Note: loading_started is not connected in this modal, so don't disconnect it
|
|
||||||
|
# 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):
|
except (RuntimeError, AttributeError):
|
||||||
# Signal may already be disconnected or worker deleted
|
# Signal may already be disconnected or worker deleted
|
||||||
pass
|
pass
|
||||||
|
|
@ -419,6 +586,9 @@ class PlaylistDetailsModal(QDialog):
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Connect download missing tracks button
|
||||||
|
download_btn.clicked.connect(self.on_download_missing_tracks_clicked)
|
||||||
|
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
button_layout.addWidget(close_btn)
|
button_layout.addWidget(close_btn)
|
||||||
button_layout.addWidget(download_btn)
|
button_layout.addWidget(download_btn)
|
||||||
|
|
@ -433,6 +603,120 @@ class PlaylistDetailsModal(QDialog):
|
||||||
seconds = seconds % 60
|
seconds = seconds % 60
|
||||||
return f"{minutes}:{seconds:02d}"
|
return f"{minutes}:{seconds:02d}"
|
||||||
|
|
||||||
|
def on_download_missing_tracks_clicked(self):
|
||||||
|
"""Handle Download Missing Tracks 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
|
||||||
|
|
||||||
|
# Get access to parent's Plex and Soulseek clients through parent reference
|
||||||
|
if not hasattr(self.parent_page, 'plex_client'):
|
||||||
|
QMessageBox.warning(self, "Service Unavailable",
|
||||||
|
"Plex client not available. Please check your configuration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start the download missing tracks workflow
|
||||||
|
try:
|
||||||
|
self.start_playlist_missing_tracks_download()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Download Error", f"Failed to start download: {str(e)}")
|
||||||
|
|
||||||
|
def start_playlist_missing_tracks_download(self):
|
||||||
|
"""Start the process of downloading missing tracks from playlist"""
|
||||||
|
track_count = len(self.playlist.tracks)
|
||||||
|
|
||||||
|
# Start analysis worker
|
||||||
|
self.start_track_analysis()
|
||||||
|
|
||||||
|
# Show analysis started message
|
||||||
|
QMessageBox.information(self, "Analysis Started",
|
||||||
|
f"Starting analysis of {track_count} tracks.\nChecking Plex library for existing tracks...")
|
||||||
|
|
||||||
|
def start_track_analysis(self):
|
||||||
|
"""Start background track analysis against Plex library"""
|
||||||
|
# Create analysis worker
|
||||||
|
plex_client = getattr(self.parent_page, 'plex_client', None)
|
||||||
|
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, plex_client)
|
||||||
|
|
||||||
|
# Connect signals
|
||||||
|
worker.signals.analysis_started.connect(self.on_analysis_started)
|
||||||
|
worker.signals.track_analyzed.connect(self.on_track_analyzed)
|
||||||
|
worker.signals.analysis_completed.connect(self.on_analysis_completed)
|
||||||
|
worker.signals.analysis_failed.connect(self.on_analysis_failed)
|
||||||
|
|
||||||
|
# Track worker for cleanup
|
||||||
|
self.active_workers.append(worker)
|
||||||
|
|
||||||
|
# Submit to thread pool
|
||||||
|
if hasattr(self.parent_page, 'thread_pool'):
|
||||||
|
self.parent_page.thread_pool.start(worker)
|
||||||
|
else:
|
||||||
|
# Create and track fallback thread pool
|
||||||
|
thread_pool = QThreadPool()
|
||||||
|
self.fallback_pools.append(thread_pool)
|
||||||
|
thread_pool.start(worker)
|
||||||
|
|
||||||
|
def on_analysis_started(self, total_tracks):
|
||||||
|
"""Handle analysis started signal"""
|
||||||
|
print(f"Started analyzing {total_tracks} tracks against Plex library")
|
||||||
|
|
||||||
|
def on_track_analyzed(self, track_index, result):
|
||||||
|
"""Handle individual track analysis completion"""
|
||||||
|
track = result.spotify_track
|
||||||
|
if result.exists_in_plex:
|
||||||
|
print(f"Track {track_index}: '{track.name}' by {track.artists[0]} EXISTS in Plex (confidence: {result.confidence:.2f})")
|
||||||
|
else:
|
||||||
|
print(f"Track {track_index}: '{track.name}' by {track.artists[0]} MISSING from Plex - will download")
|
||||||
|
|
||||||
|
def on_analysis_completed(self, results):
|
||||||
|
"""Handle analysis completion and start downloads for missing tracks"""
|
||||||
|
missing_tracks = [r for r in results if not r.exists_in_plex]
|
||||||
|
existing_tracks = [r for r in results if r.exists_in_plex]
|
||||||
|
|
||||||
|
print(f"Analysis complete: {len(missing_tracks)} missing, {len(existing_tracks)} existing")
|
||||||
|
|
||||||
|
if not missing_tracks:
|
||||||
|
QMessageBox.information(self, "Analysis Complete",
|
||||||
|
"All tracks already exist in Plex library!\nNo downloads needed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show results to user
|
||||||
|
message = f"Analysis complete!\n\n"
|
||||||
|
message += f"Tracks already in Plex: {len(existing_tracks)}\n"
|
||||||
|
message += f"Tracks to download: {len(missing_tracks)}\n\n"
|
||||||
|
message += "Ready to start downloading missing tracks?"
|
||||||
|
|
||||||
|
reply = QMessageBox.question(self, "Start Downloads?", message,
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||||
|
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
self.start_missing_track_downloads(missing_tracks)
|
||||||
|
|
||||||
|
def on_analysis_failed(self, error_message):
|
||||||
|
"""Handle analysis failure"""
|
||||||
|
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
|
||||||
|
|
||||||
|
def start_missing_track_downloads(self, missing_tracks):
|
||||||
|
"""Start downloading the missing tracks"""
|
||||||
|
# TODO: Implement Soulseek search and download queueing
|
||||||
|
# For now, just show what would be downloaded
|
||||||
|
track_list = []
|
||||||
|
for result in missing_tracks:
|
||||||
|
track = result.spotify_track
|
||||||
|
artist = track.artists[0] if track.artists else "Unknown Artist"
|
||||||
|
track_list.append(f"• {track.name} by {artist}")
|
||||||
|
|
||||||
|
message = f"Would download {len(missing_tracks)} tracks:\n\n"
|
||||||
|
message += "\n".join(track_list[:10]) # Show first 10
|
||||||
|
if len(track_list) > 10:
|
||||||
|
message += f"\n... and {len(track_list) - 10} more"
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Downloads Queued", message)
|
||||||
|
|
||||||
def load_tracks_async(self):
|
def load_tracks_async(self):
|
||||||
"""Load tracks asynchronously using worker thread"""
|
"""Load tracks asynchronously using worker thread"""
|
||||||
if not self.spotify_client:
|
if not self.spotify_client:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue