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
|
||||
- ✅ Added graceful Plex connection handling
|
||||
|
||||
2. **🔄 IN PROGRESS - Plex Integration for Track Existence Checking**
|
||||
- Implement intelligent track matching against Plex library
|
||||
- Use confidence scoring (≥0.8) to determine existing tracks
|
||||
- Handle Plex connection failures gracefully (download all tracks)
|
||||
2. **✅ COMPLETED - Plex Integration for Track Existence Checking**
|
||||
- ✅ Implement intelligent track matching against Plex library
|
||||
- ✅ Use confidence scoring (≥0.8) to determine existing 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)
|
||||
- Leverage existing search filtering and quality selection
|
||||
- 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)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject
|
||||
from PyQt6.QtGui import QFont
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
@dataclass
|
||||
class TrackAnalysisResult:
|
||||
"""Result of analyzing a track for Plex existence"""
|
||||
spotify_track: object # Spotify track object
|
||||
exists_in_plex: bool
|
||||
plex_match: Optional[object] = None # Plex track if found
|
||||
confidence: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
|
||||
class PlaylistTrackAnalysisWorkerSignals(QObject):
|
||||
"""Signals for playlist track analysis worker"""
|
||||
analysis_started = pyqtSignal(int) # total_tracks
|
||||
track_analyzed = pyqtSignal(int, object) # track_index, TrackAnalysisResult
|
||||
analysis_completed = pyqtSignal(list) # List[TrackAnalysisResult]
|
||||
analysis_failed = pyqtSignal(str) # error_message
|
||||
|
||||
class PlaylistTrackAnalysisWorker(QRunnable):
|
||||
"""Background worker to analyze playlist tracks against Plex library"""
|
||||
|
||||
def __init__(self, playlist_tracks, plex_client):
|
||||
super().__init__()
|
||||
self.playlist_tracks = playlist_tracks
|
||||
self.plex_client = plex_client
|
||||
self.signals = PlaylistTrackAnalysisWorkerSignals()
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel the analysis operation"""
|
||||
self._cancelled = True
|
||||
|
||||
def run(self):
|
||||
"""Analyze each track in the playlist"""
|
||||
try:
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
self.signals.analysis_started.emit(len(self.playlist_tracks))
|
||||
results = []
|
||||
|
||||
# Check if Plex is connected
|
||||
plex_connected = False
|
||||
try:
|
||||
if self.plex_client:
|
||||
plex_connected = self.plex_client.is_connected()
|
||||
except Exception as e:
|
||||
print(f"Plex connection check failed: {e}")
|
||||
plex_connected = False
|
||||
|
||||
for i, track in enumerate(self.playlist_tracks):
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
result = TrackAnalysisResult(
|
||||
spotify_track=track,
|
||||
exists_in_plex=False
|
||||
)
|
||||
|
||||
if plex_connected:
|
||||
# Check if track exists in Plex
|
||||
try:
|
||||
plex_match, confidence = self._check_track_in_plex(track)
|
||||
if plex_match and confidence >= 0.8: # High confidence threshold
|
||||
result.exists_in_plex = True
|
||||
result.plex_match = plex_match
|
||||
result.confidence = confidence
|
||||
except Exception as e:
|
||||
result.error_message = f"Plex check failed: {str(e)}"
|
||||
|
||||
results.append(result)
|
||||
self.signals.track_analyzed.emit(i + 1, result)
|
||||
|
||||
if not self._cancelled:
|
||||
self.signals.analysis_completed.emit(results)
|
||||
|
||||
except Exception as e:
|
||||
if not self._cancelled:
|
||||
self.signals.analysis_failed.emit(str(e))
|
||||
|
||||
def _check_track_in_plex(self, spotify_track):
|
||||
"""Check if a Spotify track exists in Plex with confidence scoring"""
|
||||
try:
|
||||
# Search Plex for similar tracks
|
||||
# Use first artist for search
|
||||
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
||||
search_query = f"{artist_name} {spotify_track.name}".strip()
|
||||
|
||||
# Get potential matches from Plex
|
||||
plex_tracks = self.plex_client.search_tracks(search_query, limit=10)
|
||||
|
||||
if not plex_tracks:
|
||||
return None, 0.0
|
||||
|
||||
# Find best match using confidence scoring
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
|
||||
for plex_track in plex_tracks:
|
||||
confidence = self._calculate_track_confidence(spotify_track, plex_track)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = plex_track
|
||||
|
||||
return best_match, best_confidence
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking track in Plex: {e}")
|
||||
return None, 0.0
|
||||
|
||||
def _calculate_track_confidence(self, spotify_track, plex_track):
|
||||
"""Calculate confidence score between Spotify and Plex tracks"""
|
||||
try:
|
||||
# Basic string similarity for now (can be enhanced with existing matching engine)
|
||||
import re
|
||||
|
||||
def normalize_string(s):
|
||||
return re.sub(r'[^a-zA-Z0-9\s]', '', s.lower()).strip()
|
||||
|
||||
# Normalize track titles
|
||||
spotify_title = normalize_string(spotify_track.name)
|
||||
plex_title = normalize_string(plex_track.title)
|
||||
|
||||
# Normalize artist names
|
||||
spotify_artist = normalize_string(spotify_track.artists[0]) if spotify_track.artists else ""
|
||||
plex_artist = normalize_string(plex_track.artist)
|
||||
|
||||
# Simple similarity scoring
|
||||
title_similarity = 1.0 if spotify_title == plex_title else 0.0
|
||||
artist_similarity = 1.0 if spotify_artist == plex_artist else 0.0
|
||||
|
||||
# Weight title more heavily
|
||||
confidence = (title_similarity * 0.7) + (artist_similarity * 0.3)
|
||||
|
||||
# Duration check (allow 10% variance)
|
||||
if hasattr(spotify_track, 'duration_ms') and hasattr(plex_track, 'duration'):
|
||||
spotify_duration = spotify_track.duration_ms / 1000
|
||||
plex_duration = plex_track.duration / 1000 if plex_track.duration else 0
|
||||
|
||||
if plex_duration > 0:
|
||||
duration_diff = abs(spotify_duration - plex_duration) / max(spotify_duration, plex_duration)
|
||||
if duration_diff <= 0.1: # Within 10%
|
||||
confidence += 0.1 # Bonus for duration match
|
||||
|
||||
return min(confidence, 1.0) # Cap at 1.0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calculating track confidence: {e}")
|
||||
return 0.0
|
||||
|
||||
class PlaylistLoaderThread(QThread):
|
||||
playlist_loaded = pyqtSignal(object) # Single playlist
|
||||
|
|
@ -125,7 +275,7 @@ class PlaylistDetailsModal(QDialog):
|
|||
for worker in self.active_workers:
|
||||
try:
|
||||
if hasattr(worker, 'signals'):
|
||||
# Only disconnect signals that are actually connected to this modal
|
||||
# Disconnect track loading worker signals
|
||||
try:
|
||||
worker.signals.tracks_loaded.disconnect(self.on_tracks_loaded)
|
||||
except (RuntimeError, TypeError):
|
||||
|
|
@ -134,7 +284,24 @@ class PlaylistDetailsModal(QDialog):
|
|||
worker.signals.loading_failed.disconnect(self.on_tracks_loading_failed)
|
||||
except (RuntimeError, TypeError):
|
||||
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):
|
||||
# Signal may already be disconnected or worker deleted
|
||||
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.addWidget(close_btn)
|
||||
button_layout.addWidget(download_btn)
|
||||
|
|
@ -433,6 +603,120 @@ class PlaylistDetailsModal(QDialog):
|
|||
seconds = seconds % 60
|
||||
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):
|
||||
"""Load tracks asynchronously using worker thread"""
|
||||
if not self.spotify_client:
|
||||
|
|
|
|||
Loading…
Reference in a new issue