|
|
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
|
|
QFrame, QGridLayout, QScrollArea, QSizePolicy, QPushButton,
|
|
|
QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout, QComboBox,
|
|
|
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QMessageBox, QApplication)
|
|
|
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject, QRunnable, QThreadPool
|
|
|
from PyQt6.QtGui import QFont, QPalette, QColor
|
|
|
import time
|
|
|
import re
|
|
|
import asyncio
|
|
|
import threading
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
try:
|
|
|
import resource
|
|
|
HAS_RESOURCE = True
|
|
|
except ImportError:
|
|
|
HAS_RESOURCE = False
|
|
|
import os
|
|
|
from typing import Optional, Dict, Any, List
|
|
|
from datetime import datetime
|
|
|
from dataclasses import dataclass
|
|
|
import requests
|
|
|
from PIL import Image
|
|
|
import io
|
|
|
from core.matching_engine import MusicMatchingEngine
|
|
|
from ui.components.database_updater_widget import DatabaseUpdaterWidget
|
|
|
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
|
|
from core.wishlist_service import get_wishlist_service
|
|
|
from core.watchlist_scanner import get_watchlist_scanner
|
|
|
from utils.logging_config import get_logger
|
|
|
|
|
|
from core.soulseek_client import TrackResult
|
|
|
from database.music_database import get_database
|
|
|
from core.plex_scan_manager import PlexScanManager
|
|
|
|
|
|
# dashboard.py - Add these helper classes
|
|
|
|
|
|
logger = get_logger("dashboard")
|
|
|
|
|
|
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
|
|
QFrame, QGridLayout, QScrollArea, QSizePolicy, QPushButton,
|
|
|
QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout, QComboBox,
|
|
|
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QMessageBox, QApplication)
|
|
|
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject, QRunnable, QThreadPool
|
|
|
from PyQt6.QtGui import QFont, QPalette, QColor
|
|
|
import time
|
|
|
import re
|
|
|
import asyncio
|
|
|
import threading
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
try:
|
|
|
import resource
|
|
|
HAS_RESOURCE = True
|
|
|
except ImportError:
|
|
|
HAS_RESOURCE = False
|
|
|
import os
|
|
|
from typing import Optional, Dict, Any, List
|
|
|
from datetime import datetime
|
|
|
from dataclasses import dataclass
|
|
|
import requests
|
|
|
from PIL import Image
|
|
|
import io
|
|
|
from core.matching_engine import MusicMatchingEngine
|
|
|
from ui.components.database_updater_widget import DatabaseUpdaterWidget
|
|
|
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
|
|
from core.wishlist_service import get_wishlist_service
|
|
|
from core.watchlist_scanner import get_watchlist_scanner
|
|
|
from utils.logging_config import get_logger
|
|
|
|
|
|
from core.soulseek_client import TrackResult
|
|
|
from database.music_database import get_database
|
|
|
from core.plex_scan_manager import PlexScanManager
|
|
|
|
|
|
# dashboard.py - Add these helper classes
|
|
|
|
|
|
logger = get_logger("dashboard")
|
|
|
|
|
|
@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)
|
|
|
track_analyzed = pyqtSignal(int, object)
|
|
|
analysis_completed = pyqtSignal(list)
|
|
|
analysis_failed = pyqtSignal(str)
|
|
|
|
|
|
class PlaylistTrackAnalysisWorker(QRunnable):
|
|
|
"""Background worker to analyze playlist tracks against the local database"""
|
|
|
def __init__(self, playlist_tracks, plex_client):
|
|
|
super().__init__()
|
|
|
self.playlist_tracks = playlist_tracks
|
|
|
self.plex_client = plex_client # Still needed for connection check
|
|
|
self.signals = PlaylistTrackAnalysisWorkerSignals()
|
|
|
self._cancelled = False
|
|
|
self.matching_engine = MusicMatchingEngine()
|
|
|
|
|
|
def cancel(self):
|
|
|
self._cancelled = True
|
|
|
|
|
|
def run(self):
|
|
|
try:
|
|
|
if self._cancelled: return
|
|
|
self.signals.analysis_started.emit(len(self.playlist_tracks))
|
|
|
results = []
|
|
|
db = get_database()
|
|
|
|
|
|
for i, track in enumerate(self.playlist_tracks):
|
|
|
if self._cancelled: return
|
|
|
|
|
|
result = TrackAnalysisResult(spotify_track=track, exists_in_plex=False)
|
|
|
try:
|
|
|
plex_match, confidence = self._check_track_in_db(track, db)
|
|
|
if plex_match and confidence >= 0.8:
|
|
|
result.exists_in_plex = True
|
|
|
result.plex_match = plex_match
|
|
|
result.confidence = confidence
|
|
|
except Exception as e:
|
|
|
result.error_message = f"DB 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_db(self, spotify_track, db):
|
|
|
"""
|
|
|
Checks if a Spotify track exists in the database.
|
|
|
This logic now relies solely on the central MusicMatchingEngine for consistency.
|
|
|
"""
|
|
|
try:
|
|
|
original_title = spotify_track.name
|
|
|
|
|
|
# The matching engine's clean_title now handles "(Original Mix)" and other noise.
|
|
|
# We create variations to be safe.
|
|
|
title_variations = [original_title]
|
|
|
cleaned_title = self.matching_engine.clean_title(original_title)
|
|
|
if cleaned_title.lower() != original_title.lower():
|
|
|
title_variations.append(cleaned_title)
|
|
|
|
|
|
unique_title_variations = list(dict.fromkeys(title_variations))
|
|
|
|
|
|
artists_to_search = spotify_track.artists if spotify_track.artists else [""]
|
|
|
for artist_name in artists_to_search:
|
|
|
if self._cancelled: return None, 0.0
|
|
|
|
|
|
for query_title in unique_title_variations:
|
|
|
if self._cancelled: return None, 0.0
|
|
|
|
|
|
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
|
|
|
|
|
|
if db_track and confidence >= 0.7:
|
|
|
class MockPlexTrack:
|
|
|
def __init__(self, db_track):
|
|
|
self.id = str(db_track.id)
|
|
|
self.title = db_track.title
|
|
|
self.artist_name = db_track.artist_name
|
|
|
self.album_title = db_track.album_title
|
|
|
self.track_number = db_track.track_number
|
|
|
self.duration = db_track.duration
|
|
|
self.file_path = db_track.file_path
|
|
|
|
|
|
mock_track = MockPlexTrack(db_track)
|
|
|
return mock_track, confidence
|
|
|
|
|
|
return None, 0.0
|
|
|
|
|
|
except Exception as e:
|
|
|
import traceback
|
|
|
print(f"Error checking track in database: {e}")
|
|
|
traceback.print_exc()
|
|
|
return None, 0.0
|
|
|
|
|
|
class SyncStatusProcessingWorkerSignals(QObject):
|
|
|
completed = pyqtSignal(list)
|
|
|
error = pyqtSignal(str)
|
|
|
|
|
|
class SyncStatusProcessingWorker(QRunnable):
|
|
|
"""Background worker for processing download status updates."""
|
|
|
def __init__(self, soulseek_client, download_items_data):
|
|
|
super().__init__()
|
|
|
self.signals = SyncStatusProcessingWorkerSignals()
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.download_items_data = download_items_data
|
|
|
|
|
|
def run(self):
|
|
|
try:
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
transfers_data = loop.run_until_complete(
|
|
|
self.soulseek_client._make_request('GET', 'transfers/downloads')
|
|
|
)
|
|
|
loop.close()
|
|
|
|
|
|
results = []
|
|
|
if not transfers_data:
|
|
|
transfers_data = []
|
|
|
|
|
|
all_transfers = []
|
|
|
for user_data in transfers_data:
|
|
|
if 'files' in user_data and isinstance(user_data['files'], list):
|
|
|
all_transfers.extend(user_data['files'])
|
|
|
if 'directories' in user_data and isinstance(user_data['directories'], list):
|
|
|
for directory in user_data['directories']:
|
|
|
if 'files' in directory and isinstance(directory['files'], list):
|
|
|
all_transfers.extend(directory['files'])
|
|
|
|
|
|
transfers_by_id = {t['id']: t for t in all_transfers}
|
|
|
|
|
|
for item_data in self.download_items_data:
|
|
|
matching_transfer = None
|
|
|
if item_data.get('download_id'):
|
|
|
matching_transfer = transfers_by_id.get(item_data['download_id'])
|
|
|
|
|
|
if not matching_transfer:
|
|
|
expected_basename = os.path.basename(item_data['file_path']).lower()
|
|
|
for t in all_transfers:
|
|
|
api_basename = os.path.basename(t.get('filename', '')).lower()
|
|
|
if api_basename == expected_basename:
|
|
|
matching_transfer = t
|
|
|
break
|
|
|
|
|
|
if matching_transfer:
|
|
|
state = matching_transfer.get('state', 'Unknown')
|
|
|
progress = matching_transfer.get('percentComplete', 0)
|
|
|
|
|
|
if 'Cancelled' in state or 'Canceled' in state: new_status = 'cancelled'
|
|
|
elif 'Failed' in state or 'Errored' in state: new_status = 'failed'
|
|
|
elif 'Completed' in state or 'Succeeded' in state: new_status = 'completed'
|
|
|
elif 'InProgress' in state: new_status = 'downloading'
|
|
|
else: new_status = 'queued'
|
|
|
|
|
|
payload = {
|
|
|
'widget_id': item_data['widget_id'],
|
|
|
'status': new_status,
|
|
|
'progress': int(progress),
|
|
|
'transfer_id': matching_transfer.get('id'),
|
|
|
'username': matching_transfer.get('username')
|
|
|
}
|
|
|
results.append(payload)
|
|
|
else:
|
|
|
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'}
|
|
|
results.append(payload)
|
|
|
|
|
|
self.signals.completed.emit(results)
|
|
|
except Exception as e:
|
|
|
self.signals.error.emit(str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# dashboard.py - Replace the old modal class with this new one
|
|
|
|
|
|
class DownloadMissingWishlistTracksModal(QDialog):
|
|
|
"""
|
|
|
Enhanced modal for downloading missing wishlist tracks with live progress tracking.
|
|
|
Functionality is extended from the modals in sync.py and artists.py.
|
|
|
"""
|
|
|
process_finished = pyqtSignal()
|
|
|
|
|
|
def __init__(self, wishlist_service, parent_dashboard, downloads_page, spotify_client, plex_client, soulseek_client):
|
|
|
super().__init__(parent_dashboard)
|
|
|
self.wishlist_service = wishlist_service
|
|
|
self.parent_dashboard = parent_dashboard
|
|
|
self.downloads_page = downloads_page
|
|
|
self.spotify_client = spotify_client
|
|
|
self.plex_client = plex_client
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.matching_engine = MusicMatchingEngine()
|
|
|
|
|
|
# State tracking
|
|
|
self.wishlist_tracks = []
|
|
|
self.total_tracks = 0
|
|
|
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
|
|
|
self.cancel_requested = False
|
|
|
self.permanently_failed_tracks = []
|
|
|
self.cancelled_tracks = set() # Track indices of cancelled tracks
|
|
|
self.analysis_results = []
|
|
|
self.missing_tracks = []
|
|
|
self.active_workers = []
|
|
|
self.fallback_pools = []
|
|
|
self.active_downloads = []
|
|
|
|
|
|
# Status Polling
|
|
|
self.download_status_pool = QThreadPool()
|
|
|
self.download_status_pool.setMaxThreadCount(1)
|
|
|
self._is_status_update_running = False
|
|
|
self.download_status_timer = QTimer(self)
|
|
|
self.download_status_timer.timeout.connect(self.poll_all_download_statuses)
|
|
|
self.download_status_timer.start(2000)
|
|
|
|
|
|
self.setup_ui()
|
|
|
self.load_and_populate_tracks()
|
|
|
|
|
|
def start_search(self):
|
|
|
"""
|
|
|
Public method to start the search process. Can be called externally.
|
|
|
This will trigger the same action as clicking the 'Begin Search' button.
|
|
|
"""
|
|
|
if not self.download_in_progress:
|
|
|
self.on_begin_search_clicked()
|
|
|
|
|
|
def load_and_populate_tracks(self):
|
|
|
"""Fetches tracks from the wishlist service and prepares them for the modal."""
|
|
|
|
|
|
# A simple dataclass to mimic the structure of a Spotify track object
|
|
|
# that the rest of the modal logic expects.
|
|
|
@dataclass
|
|
|
class MockSpotifyTrack:
|
|
|
id: str
|
|
|
name: str
|
|
|
artists: List[str]
|
|
|
album: str
|
|
|
duration_ms: int = 0
|
|
|
|
|
|
try:
|
|
|
wishlist_data = self.wishlist_service.get_wishlist_tracks_for_download()
|
|
|
self.wishlist_tracks = []
|
|
|
for track_data in wishlist_data:
|
|
|
# Convert artist dicts like [{'name': 'Artist'}] to a simple list ['Artist']
|
|
|
artist_list = [artist['name'] for artist in track_data.get('artists', []) if 'name' in artist]
|
|
|
|
|
|
mock_track = MockSpotifyTrack(
|
|
|
id=track_data.get('spotify_track_id', ''),
|
|
|
name=track_data.get('name', 'Unknown Track'),
|
|
|
artists=artist_list,
|
|
|
album=track_data.get('album_name', 'Unknown Album')
|
|
|
)
|
|
|
self.wishlist_tracks.append(mock_track)
|
|
|
|
|
|
self.total_tracks = len(self.wishlist_tracks)
|
|
|
self.total_count_label.setText(str(self.total_tracks))
|
|
|
self.populate_track_table()
|
|
|
|
|
|
# Update button states after loading tracks
|
|
|
self._update_button_states()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Failed to load wishlist tracks: {e}")
|
|
|
QMessageBox.critical(self, "Error", f"Could not load wishlist tracks: {e}")
|
|
|
|
|
|
def setup_ui(self):
|
|
|
self.setWindowTitle("Download Wishlist Tracks")
|
|
|
self.resize(1200, 900)
|
|
|
self.setWindowFlags(Qt.WindowType.Window)
|
|
|
|
|
|
self.setStyleSheet("""
|
|
|
QDialog { background-color: #1e1e1e; color: #ffffff; }
|
|
|
QLabel { color: #ffffff; }
|
|
|
QPushButton {
|
|
|
background-color: #1db954; color: #000000; border: none;
|
|
|
border-radius: 6px; font-size: 13px; font-weight: bold;
|
|
|
padding: 10px 20px; min-width: 100px;
|
|
|
}
|
|
|
QPushButton:hover { background-color: #1ed760; }
|
|
|
QPushButton:disabled { background-color: #404040; color: #888888; }
|
|
|
""")
|
|
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
|
main_layout.setContentsMargins(25, 25, 25, 25)
|
|
|
main_layout.setSpacing(15)
|
|
|
|
|
|
top_section = self.create_compact_top_section()
|
|
|
main_layout.addWidget(top_section)
|
|
|
|
|
|
progress_section = self.create_progress_section()
|
|
|
main_layout.addWidget(progress_section)
|
|
|
|
|
|
table_section = self.create_track_table()
|
|
|
main_layout.addWidget(table_section, stretch=1)
|
|
|
|
|
|
button_section = self.create_buttons()
|
|
|
main_layout.addWidget(button_section)
|
|
|
|
|
|
def create_compact_top_section(self):
|
|
|
top_frame = QFrame()
|
|
|
top_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 15px;")
|
|
|
layout = QVBoxLayout(top_frame)
|
|
|
header_layout = QHBoxLayout()
|
|
|
title_section = QVBoxLayout()
|
|
|
|
|
|
title = QLabel("Download Wishlist Tracks")
|
|
|
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
|
title.setStyleSheet("color: #1db954;")
|
|
|
|
|
|
subtitle = QLabel("Processing tracks from your wishlist")
|
|
|
subtitle.setFont(QFont("Arial", 11))
|
|
|
subtitle.setStyleSheet("color: #aaaaaa;")
|
|
|
|
|
|
title_section.addWidget(title)
|
|
|
title_section.addWidget(subtitle)
|
|
|
|
|
|
dashboard_layout = QHBoxLayout()
|
|
|
self.total_card = self.create_compact_counter_card("📀 Total", "0", "#1db954")
|
|
|
self.matched_card = self.create_compact_counter_card("✅ Found", "0", "#4CAF50")
|
|
|
self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b")
|
|
|
self.downloaded_card = self.create_compact_counter_card("✅ Downloaded", "0", "#4CAF50")
|
|
|
dashboard_layout.addWidget(self.total_card)
|
|
|
dashboard_layout.addWidget(self.matched_card)
|
|
|
dashboard_layout.addWidget(self.download_card)
|
|
|
dashboard_layout.addWidget(self.downloaded_card)
|
|
|
|
|
|
header_layout.addLayout(title_section)
|
|
|
header_layout.addStretch()
|
|
|
header_layout.addLayout(dashboard_layout)
|
|
|
layout.addLayout(header_layout)
|
|
|
return top_frame
|
|
|
|
|
|
def create_compact_counter_card(self, title, count, color):
|
|
|
card = QFrame()
|
|
|
card.setStyleSheet(f"background-color: #3a3a3a; border: 2px solid {color}; border-radius: 6px; padding: 8px 12px; min-width: 80px;")
|
|
|
layout = QVBoxLayout(card)
|
|
|
count_label = QLabel(count)
|
|
|
count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
|
count_label.setStyleSheet(f"color: {color}; background: transparent;")
|
|
|
count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
title_label = QLabel(title)
|
|
|
title_label.setFont(QFont("Arial", 9))
|
|
|
title_label.setStyleSheet("color: #cccccc; background: transparent;")
|
|
|
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
layout.addWidget(count_label)
|
|
|
layout.addWidget(title_label)
|
|
|
if "Total" in title: self.total_count_label = count_label
|
|
|
elif "Found" in title: self.matched_count_label = count_label
|
|
|
elif "Missing" in title: self.download_count_label = count_label
|
|
|
elif "Downloaded" in title: self.downloaded_count_label = count_label
|
|
|
return card
|
|
|
|
|
|
def create_progress_section(self):
|
|
|
progress_frame = QFrame()
|
|
|
progress_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 12px;")
|
|
|
layout = QVBoxLayout(progress_frame)
|
|
|
analysis_container = QVBoxLayout()
|
|
|
analysis_label = QLabel("🔍 Library Analysis")
|
|
|
analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
|
|
self.analysis_progress = QProgressBar()
|
|
|
self.analysis_progress.setFixedHeight(20)
|
|
|
self.analysis_progress.setStyleSheet("QProgressBar { border: 1px solid #555; border-radius: 10px; text-align: center; background-color: #444; color: #fff; font-size: 11px; } QProgressBar::chunk { background-color: #1db954; border-radius: 9px; }")
|
|
|
self.analysis_progress.setVisible(False)
|
|
|
analysis_container.addWidget(analysis_label)
|
|
|
analysis_container.addWidget(self.analysis_progress)
|
|
|
download_container = QVBoxLayout()
|
|
|
download_label = QLabel("⬇️ Download Progress")
|
|
|
download_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
|
|
self.download_progress = QProgressBar()
|
|
|
self.download_progress.setFixedHeight(20)
|
|
|
self.download_progress.setStyleSheet("QProgressBar { border: 1px solid #555; border-radius: 10px; text-align: center; background-color: #444; color: #fff; font-size: 11px; } 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 without the Duration column."""
|
|
|
table_frame = QFrame()
|
|
|
table_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 0px;")
|
|
|
layout = QVBoxLayout(table_frame)
|
|
|
layout.setContentsMargins(15, 15, 15, 15)
|
|
|
|
|
|
self.track_table = QTableWidget()
|
|
|
# Change column count from 4 to 5 for Cancel column
|
|
|
self.track_table.setColumnCount(5)
|
|
|
# Add "Cancel" column (no Duration column)
|
|
|
self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Matched", "Status", "Cancel"])
|
|
|
|
|
|
# Adjust resize modes for column indices
|
|
|
self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
|
self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # "Matched" is column 2
|
|
|
self.track_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # "Cancel" is column 4
|
|
|
self.track_table.setColumnWidth(2, 140) # Set width for "Matched" column
|
|
|
self.track_table.setColumnWidth(4, 70) # Set width for "Cancel" column
|
|
|
|
|
|
self.track_table.setStyleSheet("QTableWidget { background-color: #3a3a3a; alternate-background-color: #424242; selection-background-color: #1db954; gridline-color: #555; color: #fff; border: 1px solid #555; font-size: 12px; } QHeaderView::section { background-color: #1db954; color: #000; font-weight: bold; font-size: 13px; padding: 12px 8px; border: none; } QTableWidget::item { padding: 12px 8px; border-bottom: 1px solid #4a4a4a; }")
|
|
|
self.track_table.setAlternatingRowColors(True)
|
|
|
self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
|
self.track_table.verticalHeader().setDefaultSectionSize(50)
|
|
|
self.track_table.verticalHeader().setVisible(False)
|
|
|
|
|
|
layout.addWidget(self.track_table)
|
|
|
return table_frame
|
|
|
|
|
|
def populate_track_table(self):
|
|
|
"""Populate track table with wishlist tracks, omitting the duration."""
|
|
|
self.track_table.setRowCount(len(self.wishlist_tracks))
|
|
|
for i, track in enumerate(self.wishlist_tracks):
|
|
|
self.track_table.setItem(i, 0, QTableWidgetItem(track.name))
|
|
|
artist_name = track.artists[0] if track.artists else "Unknown"
|
|
|
self.track_table.setItem(i, 1, QTableWidgetItem(artist_name))
|
|
|
|
|
|
# --- DURATION LOGIC REMOVED ---
|
|
|
|
|
|
# "Matched" is now column 2
|
|
|
matched_item = QTableWidgetItem("⏳ Pending")
|
|
|
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
self.track_table.setItem(i, 2, matched_item)
|
|
|
|
|
|
# "Status" is now column 3
|
|
|
status_item = QTableWidgetItem("—")
|
|
|
status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
self.track_table.setItem(i, 3, status_item)
|
|
|
|
|
|
# Create empty container for cancel button (will be populated later for missing tracks only)
|
|
|
container = QWidget()
|
|
|
container.setStyleSheet("background: transparent;")
|
|
|
layout = QVBoxLayout(container)
|
|
|
layout.setContentsMargins(5, 5, 5, 5)
|
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
|
|
self.track_table.setCellWidget(i, 4, container)
|
|
|
|
|
|
# Loop over 4 columns instead of 5 (don't include cancel column)
|
|
|
for col in range(4):
|
|
|
item = self.track_table.item(i, col)
|
|
|
if item:
|
|
|
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
|
|
|
|
def format_duration(self, duration_ms):
|
|
|
if not duration_ms: return "0:00"
|
|
|
seconds = duration_ms // 1000
|
|
|
return f"{seconds // 60}:{seconds % 60:02d}"
|
|
|
|
|
|
def add_cancel_button_to_row(self, row):
|
|
|
"""Add cancel button to a specific row (only for missing tracks)"""
|
|
|
container = self.track_table.cellWidget(row, 4)
|
|
|
if container and container.layout().count() == 0: # Only add if container is empty
|
|
|
cancel_button = QPushButton("×")
|
|
|
cancel_button.setFixedSize(20, 20)
|
|
|
cancel_button.setMinimumSize(20, 20)
|
|
|
cancel_button.setMaximumSize(20, 20)
|
|
|
cancel_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background-color: #dc3545;
|
|
|
color: white;
|
|
|
border: 1px solid #c82333;
|
|
|
border-radius: 3px;
|
|
|
font-size: 14px;
|
|
|
font-weight: bold;
|
|
|
padding: 0px;
|
|
|
margin: 0px;
|
|
|
text-align: center;
|
|
|
min-width: 20px;
|
|
|
max-width: 20px;
|
|
|
width: 20px;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background-color: #c82333;
|
|
|
border-color: #bd2130;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background-color: #bd2130;
|
|
|
border-color: #b21f2d;
|
|
|
}
|
|
|
QPushButton:disabled {
|
|
|
background-color: #28a745;
|
|
|
color: white;
|
|
|
border-color: #1e7e34;
|
|
|
}
|
|
|
""")
|
|
|
cancel_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
|
cancel_button.clicked.connect(lambda checked, row_idx=row: self.cancel_track(row_idx))
|
|
|
|
|
|
layout = container.layout()
|
|
|
layout.addWidget(cancel_button)
|
|
|
|
|
|
def hide_cancel_button_for_row(self, row):
|
|
|
"""Hide cancel button for a specific row (when track is downloaded)"""
|
|
|
container = self.track_table.cellWidget(row, 4)
|
|
|
if container:
|
|
|
layout = container.layout()
|
|
|
if layout and layout.count() > 0:
|
|
|
cancel_button = layout.itemAt(0).widget()
|
|
|
if cancel_button:
|
|
|
cancel_button.setVisible(False)
|
|
|
print(f"🫥 Hidden cancel button for downloaded track at row {row}")
|
|
|
|
|
|
def cancel_track(self, row):
|
|
|
"""Cancel a specific track - works at any phase"""
|
|
|
# Get cancel button and disable it
|
|
|
container = self.track_table.cellWidget(row, 4)
|
|
|
if container:
|
|
|
layout = container.layout()
|
|
|
if layout and layout.count() > 0:
|
|
|
cancel_button = layout.itemAt(0).widget()
|
|
|
if cancel_button:
|
|
|
cancel_button.setEnabled(False)
|
|
|
cancel_button.setText("✓")
|
|
|
|
|
|
# Update status to cancelled (column 3 for dashboard)
|
|
|
self.track_table.setItem(row, 3, QTableWidgetItem("🚫 Cancelled"))
|
|
|
|
|
|
# Add to cancelled tracks set
|
|
|
if not hasattr(self, 'cancelled_tracks'):
|
|
|
self.cancelled_tracks = set()
|
|
|
self.cancelled_tracks.add(row)
|
|
|
|
|
|
track = self.wishlist_tracks[row]
|
|
|
print(f"🚫 Track cancelled: {track.name} (row {row})")
|
|
|
|
|
|
# If downloads are active, also handle active download cancellation
|
|
|
download_index = None
|
|
|
|
|
|
# Check active_downloads list
|
|
|
if hasattr(self, 'active_downloads'):
|
|
|
for download in self.active_downloads:
|
|
|
if download.get('table_index') == row:
|
|
|
download_index = download.get('download_index', row)
|
|
|
print(f"🚫 Found active download {download_index} for cancelled track")
|
|
|
break
|
|
|
|
|
|
# Check parallel_search_tracking for download index
|
|
|
if download_index is None and hasattr(self, 'parallel_search_tracking'):
|
|
|
for idx, track_info in self.parallel_search_tracking.items():
|
|
|
if track_info.get('table_index') == row:
|
|
|
download_index = idx
|
|
|
print(f"🚫 Found parallel tracking {download_index} for cancelled track")
|
|
|
break
|
|
|
|
|
|
# If we found an active download, trigger completion to free up the worker
|
|
|
if download_index is not None and hasattr(self, 'on_parallel_track_completed'):
|
|
|
print(f"🚫 Triggering completion for active download {download_index}")
|
|
|
self.on_parallel_track_completed(download_index, success=False)
|
|
|
|
|
|
def create_buttons(self):
|
|
|
button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;")
|
|
|
layout = QHBoxLayout(button_frame)
|
|
|
self.correct_failed_btn = QPushButton("🔧 Correct Failed Matches")
|
|
|
self.correct_failed_btn.setFixedWidth(220)
|
|
|
self.correct_failed_btn.setStyleSheet("QPushButton { background-color: #ffc107; color: #000; border-radius: 20px; font-weight: bold; }")
|
|
|
self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked)
|
|
|
self.correct_failed_btn.hide()
|
|
|
self.clear_wishlist_btn = QPushButton("🗑️ Clear Wishlist")
|
|
|
self.clear_wishlist_btn.setFixedSize(150, 40)
|
|
|
self.clear_wishlist_btn.setStyleSheet("QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px; font-size: 14px; font-weight: bold; }")
|
|
|
self.clear_wishlist_btn.clicked.connect(self.on_clear_wishlist_clicked)
|
|
|
self.begin_search_btn = QPushButton("Begin Search")
|
|
|
self.begin_search_btn.setFixedSize(160, 40)
|
|
|
self.begin_search_btn.setStyleSheet("QPushButton { background-color: #1db954; color: #000; border: none; border-radius: 20px; font-size: 14px; font-weight: bold; }")
|
|
|
self.begin_search_btn.clicked.connect(self.on_begin_search_clicked)
|
|
|
self.cancel_btn = QPushButton("Cancel")
|
|
|
self.cancel_btn.setFixedSize(110, 40)
|
|
|
self.cancel_btn.setStyleSheet("QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px;}")
|
|
|
self.cancel_btn.clicked.connect(self.on_cancel_clicked)
|
|
|
self.cancel_btn.hide()
|
|
|
self.close_btn = QPushButton("Close")
|
|
|
self.close_btn.setFixedSize(110, 40)
|
|
|
self.close_btn.setStyleSheet("QPushButton { background-color: #616161; color: #fff; border-radius: 20px;}")
|
|
|
self.close_btn.clicked.connect(self.on_close_clicked)
|
|
|
layout.addStretch()
|
|
|
layout.addWidget(self.clear_wishlist_btn)
|
|
|
layout.addWidget(self.begin_search_btn)
|
|
|
layout.addWidget(self.cancel_btn)
|
|
|
layout.addWidget(self.correct_failed_btn)
|
|
|
layout.addWidget(self.close_btn)
|
|
|
return button_frame
|
|
|
|
|
|
# --- All the logic methods from sync.py's modal ---
|
|
|
# (on_begin_search_clicked, start_plex_analysis, on_analysis_completed, etc.)
|
|
|
# are copied here without change, except for the modifications noted below.
|
|
|
|
|
|
def on_begin_search_clicked(self):
|
|
|
self.parent_dashboard.auto_processing_wishlist = True
|
|
|
self.begin_search_btn.hide()
|
|
|
self.cancel_btn.show()
|
|
|
self.analysis_progress.setVisible(True)
|
|
|
self.analysis_progress.setMaximum(self.total_tracks)
|
|
|
self.analysis_progress.setValue(0)
|
|
|
self.download_in_progress = True
|
|
|
self._update_button_states()
|
|
|
self.start_plex_analysis()
|
|
|
|
|
|
def start_plex_analysis(self):
|
|
|
# This now uses the mock track objects from the wishlist
|
|
|
worker = PlaylistTrackAnalysisWorker(self.wishlist_tracks, self.plex_client)
|
|
|
worker.signals.analysis_started.connect(self.on_analysis_started)
|
|
|
worker.signals.track_analyzed.connect(self.on_track_analyzed)
|
|
|
worker.signals.analysis_completed.connect(self.on_analysis_completed)
|
|
|
worker.signals.analysis_failed.connect(self.on_analysis_failed)
|
|
|
self.active_workers.append(worker)
|
|
|
QThreadPool.globalInstance().start(worker)
|
|
|
|
|
|
def find_track_index_in_playlist(self, spotify_track):
|
|
|
"""Finds the table row index for a given track from the wishlist."""
|
|
|
for i, track in enumerate(self.wishlist_tracks):
|
|
|
if track.id == spotify_track.id:
|
|
|
return i
|
|
|
return -1 # Return -1 if not found
|
|
|
|
|
|
# ... Paste the rest of the methods from DownloadMissingTracksModal in sync.py here ...
|
|
|
# (on_analysis_started, on_track_analyzed, on_analysis_completed, on_analysis_failed,
|
|
|
# start_download_progress, start_parallel_downloads, start_next_batch_of_downloads,
|
|
|
# search_and_download_track_parallel, start_track_search_with_queries_parallel,
|
|
|
# start_search_worker_parallel, on_search_query_completed_parallel,
|
|
|
# start_validated_download_parallel, start_matched_download_via_infrastructure_parallel,
|
|
|
# poll_all_download_statuses, _handle_processed_status_updates,
|
|
|
# cancel_download_before_retry, retry_parallel_download_with_fallback,
|
|
|
# on_parallel_track_completed, on_parallel_track_failed,
|
|
|
# update_failed_matches_button, on_correct_failed_matches_clicked,
|
|
|
# on_manual_match_resolved, on_all_downloads_complete, on_cancel_clicked,
|
|
|
# on_close_clicked, cancel_operations, closeEvent, ParallelSearchWorker,
|
|
|
# get_valid_candidates, create_spotify_based_search_result_from_validation,
|
|
|
# generate_smart_search_queries)
|
|
|
|
|
|
# NOTE: I am pasting all the required methods below for completeness.
|
|
|
|
|
|
def on_analysis_started(self, total_tracks):
|
|
|
logger.debug(f"Analysis started for {total_tracks} tracks")
|
|
|
|
|
|
def on_track_analyzed(self, track_index, result):
|
|
|
self.analysis_progress.setValue(track_index)
|
|
|
row_index = track_index - 1
|
|
|
if result.exists_in_plex:
|
|
|
matched_text = f"✅ Found ({result.confidence:.1f})"
|
|
|
self.matched_tracks_count += 1
|
|
|
self.matched_count_label.setText(str(self.matched_tracks_count))
|
|
|
|
|
|
track_id_to_remove = result.spotify_track.id
|
|
|
|
|
|
if self.wishlist_service.remove_track_from_wishlist(track_id_to_remove):
|
|
|
logger.info(f"Removed pre-existing track '{result.spotify_track.name}' from wishlist during analysis.")
|
|
|
else:
|
|
|
logger.warning(f"Could not remove pre-existing track '{track_id_to_remove}' from wishlist.")
|
|
|
|
|
|
else:
|
|
|
matched_text = "❌ Missing"
|
|
|
self.tracks_to_download_count += 1
|
|
|
self.download_count_label.setText(str(self.tracks_to_download_count))
|
|
|
# Add cancel button for missing tracks only
|
|
|
self.add_cancel_button_to_row(row_index)
|
|
|
self.track_table.setItem(row_index, 2, QTableWidgetItem(matched_text))
|
|
|
|
|
|
|
|
|
def on_analysis_completed(self, results):
|
|
|
self.analysis_complete = True
|
|
|
self.analysis_results = results
|
|
|
self.missing_tracks = [r for r in results if not r.exists_in_plex]
|
|
|
logger.info(f"Analysis complete: {len(self.missing_tracks)} to download")
|
|
|
if self.missing_tracks:
|
|
|
self.start_download_progress()
|
|
|
else:
|
|
|
self.download_in_progress = False
|
|
|
self._update_button_states()
|
|
|
self.cancel_btn.hide()
|
|
|
self.process_finished.emit()
|
|
|
QMessageBox.information(self, "Analysis Complete", "All wishlist tracks already exist in your library!")
|
|
|
|
|
|
def on_analysis_failed(self, error_message):
|
|
|
logger.error(f"Analysis failed: {error_message}")
|
|
|
QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}")
|
|
|
self.cancel_btn.hide()
|
|
|
self.begin_search_btn.show()
|
|
|
|
|
|
def start_download_progress(self):
|
|
|
self.download_progress.setVisible(True)
|
|
|
self.download_progress.setMaximum(len(self.missing_tracks))
|
|
|
self.download_progress.setValue(0)
|
|
|
self.start_parallel_downloads()
|
|
|
|
|
|
def start_parallel_downloads(self):
|
|
|
self.active_parallel_downloads = 0
|
|
|
self.download_queue_index = 0
|
|
|
self.failed_downloads = 0
|
|
|
self.completed_downloads = 0
|
|
|
self.successful_downloads = 0
|
|
|
self.start_next_batch_of_downloads()
|
|
|
|
|
|
def start_next_batch_of_downloads(self, max_concurrent=3):
|
|
|
while (self.active_parallel_downloads < max_concurrent and
|
|
|
self.download_queue_index < len(self.missing_tracks)):
|
|
|
track_result = self.missing_tracks[self.download_queue_index]
|
|
|
track = track_result.spotify_track
|
|
|
track_index = self.find_track_index_in_playlist(track)
|
|
|
if track_index != -1:
|
|
|
# Skip if track was cancelled
|
|
|
if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks:
|
|
|
print(f"🚫 Skipping cancelled track at index {track_index}: {track.name}")
|
|
|
self.download_queue_index += 1
|
|
|
self.completed_downloads += 1
|
|
|
continue
|
|
|
|
|
|
# FIX: Changed column index from 4 to 3 to target the "Status" column.
|
|
|
self.track_table.setItem(track_index, 3, QTableWidgetItem("🔍 Searching..."))
|
|
|
self.search_and_download_track_parallel(track, self.download_queue_index, track_index)
|
|
|
self.active_parallel_downloads += 1
|
|
|
self.download_queue_index += 1
|
|
|
|
|
|
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_parallel(self, spotify_track, download_index, track_index):
|
|
|
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
|
search_queries = self.generate_smart_search_queries(artist_name, spotify_track.name)
|
|
|
self.start_track_search_with_queries_parallel(spotify_track, search_queries, track_index, track_index, download_index)
|
|
|
|
|
|
def start_track_search_with_queries_parallel(self, spotify_track, search_queries, track_index, table_index, download_index):
|
|
|
if not hasattr(self, 'parallel_search_tracking'):
|
|
|
self.parallel_search_tracking = {}
|
|
|
self.parallel_search_tracking[download_index] = {
|
|
|
'spotify_track': spotify_track, 'track_index': track_index,
|
|
|
'table_index': table_index, 'download_index': download_index,
|
|
|
'completed': False, 'used_sources': set(), 'candidates': [], 'retry_count': 0
|
|
|
}
|
|
|
self.start_search_worker_parallel(search_queries, spotify_track, track_index, table_index, 0, download_index)
|
|
|
|
|
|
def start_search_worker_parallel(self, queries, spotify_track, track_index, table_index, query_index, download_index):
|
|
|
if query_index >= len(queries):
|
|
|
self.on_parallel_track_failed(download_index, "All search strategies failed")
|
|
|
return
|
|
|
query = queries[query_index]
|
|
|
worker = self.ParallelSearchWorker(self.soulseek_client, query)
|
|
|
worker.signals.search_completed.connect(lambda r, q: self.on_search_query_completed_parallel(r, queries, spotify_track, track_index, table_index, query_index, q, download_index))
|
|
|
worker.signals.search_failed.connect(lambda q, e: self.on_search_query_completed_parallel([], queries, spotify_track, track_index, table_index, query_index, q, download_index))
|
|
|
QThreadPool.globalInstance().start(worker)
|
|
|
|
|
|
def on_search_query_completed_parallel(self, results, queries, spotify_track, track_index, table_index, query_index, query, download_index):
|
|
|
if self.cancel_requested: return
|
|
|
valid_candidates = self.get_valid_candidates(results, spotify_track, query)
|
|
|
if valid_candidates:
|
|
|
self.parallel_search_tracking[download_index]['candidates'] = valid_candidates
|
|
|
best_match = valid_candidates[0]
|
|
|
self.start_validated_download_parallel(best_match, spotify_track, track_index, table_index, download_index)
|
|
|
return
|
|
|
next_query_index = query_index + 1
|
|
|
if next_query_index < len(queries):
|
|
|
self.start_search_worker_parallel(queries, spotify_track, track_index, table_index, next_query_index, download_index)
|
|
|
else:
|
|
|
self.on_parallel_track_failed(download_index, f"No valid results after trying all {len(queries)} queries.")
|
|
|
|
|
|
def start_validated_download_parallel(self, slskd_result, spotify_metadata, track_index, table_index, download_index):
|
|
|
track_info = self.parallel_search_tracking[download_index]
|
|
|
if track_info.get('completed', False):
|
|
|
track_info['completed'] = False
|
|
|
if self.failed_downloads > 0: self.failed_downloads -= 1
|
|
|
self.active_parallel_downloads += 1
|
|
|
if self.completed_downloads > 0: self.completed_downloads -= 1
|
|
|
source_key = f"{getattr(slskd_result, 'username', 'unknown')}_{slskd_result.filename}"
|
|
|
track_info['used_sources'].add(source_key)
|
|
|
spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata)
|
|
|
self.track_table.setItem(table_index, 3, QTableWidgetItem("... Queued"))
|
|
|
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):
|
|
|
try:
|
|
|
artist = type('Artist', (), {'name': spotify_based_result.artist})()
|
|
|
download_item = self.downloads_page._start_download_with_artist(spotify_based_result, artist)
|
|
|
if download_item:
|
|
|
self.active_downloads.append({
|
|
|
'download_index': download_index, 'track_index': track_index,
|
|
|
'table_index': table_index, 'download_id': download_item.download_id,
|
|
|
'slskd_result': spotify_based_result, 'candidates': self.parallel_search_tracking[download_index]['candidates']
|
|
|
})
|
|
|
else:
|
|
|
self.on_parallel_track_failed(download_index, "Failed to start download")
|
|
|
except Exception as e:
|
|
|
self.on_parallel_track_failed(download_index, str(e))
|
|
|
|
|
|
def poll_all_download_statuses(self):
|
|
|
if self._is_status_update_running or not self.active_downloads: return
|
|
|
self._is_status_update_running = True
|
|
|
items_to_check = []
|
|
|
for d in self.active_downloads:
|
|
|
if d.get('slskd_result') and hasattr(d['slskd_result'], 'filename'):
|
|
|
items_to_check.append({
|
|
|
'widget_id': d['download_index'],
|
|
|
'download_id': d.get('download_id'),
|
|
|
'file_path': d['slskd_result'].filename,
|
|
|
'api_missing_count': d.get('api_missing_count', 0)
|
|
|
})
|
|
|
if not items_to_check:
|
|
|
self._is_status_update_running = False
|
|
|
return
|
|
|
worker = SyncStatusProcessingWorker(self.soulseek_client, items_to_check)
|
|
|
worker.signals.completed.connect(self._handle_processed_status_updates)
|
|
|
worker.signals.error.connect(lambda e: logger.error(f"Status Worker Error: {e}"))
|
|
|
self.download_status_pool.start(worker)
|
|
|
|
|
|
def _handle_processed_status_updates(self, results):
|
|
|
import time
|
|
|
active_downloads_map = {d['download_index']: d for d in self.active_downloads}
|
|
|
for result in results:
|
|
|
download_index = result['widget_id']
|
|
|
new_status = result['status']
|
|
|
download_info = active_downloads_map.get(download_index)
|
|
|
if not download_info: continue
|
|
|
if 'api_missing_count' in result:
|
|
|
download_info['api_missing_count'] = result['api_missing_count']
|
|
|
if result.get('transfer_id') and download_info.get('download_id') != result['transfer_id']:
|
|
|
download_info['download_id'] = result['transfer_id']
|
|
|
if new_status in ['failed', 'cancelled']:
|
|
|
if download_info in self.active_downloads: self.active_downloads.remove(download_info)
|
|
|
self.retry_parallel_download_with_fallback(download_info)
|
|
|
elif new_status == 'completed':
|
|
|
if download_info in self.active_downloads: self.active_downloads.remove(download_info)
|
|
|
self.on_parallel_track_completed(download_index, success=True)
|
|
|
elif new_status == 'downloading':
|
|
|
progress = result.get('progress', 0)
|
|
|
self.track_table.setItem(download_info['table_index'], 3, QTableWidgetItem(f"⏬ Downloading ({progress}%)"))
|
|
|
if 'queued_start_time' in download_info: del download_info['queued_start_time']
|
|
|
if progress < 1:
|
|
|
if 'downloading_start_time' not in download_info:
|
|
|
download_info['downloading_start_time'] = time.time()
|
|
|
elif time.time() - download_info['downloading_start_time'] > 90:
|
|
|
self.cancel_download_before_retry(download_info)
|
|
|
if download_info in self.active_downloads: self.active_downloads.remove(download_info)
|
|
|
self.retry_parallel_download_with_fallback(download_info)
|
|
|
else:
|
|
|
if 'downloading_start_time' in download_info: del download_info['downloading_start_time']
|
|
|
elif new_status == 'queued':
|
|
|
self.track_table.setItem(download_info['table_index'], 3, QTableWidgetItem("... Queued"))
|
|
|
if 'queued_start_time' not in download_info:
|
|
|
download_info['queued_start_time'] = time.time()
|
|
|
elif time.time() - download_info['queued_start_time'] > 90:
|
|
|
self.cancel_download_before_retry(download_info)
|
|
|
if download_info in self.active_downloads: self.active_downloads.remove(download_info)
|
|
|
self.retry_parallel_download_with_fallback(download_info)
|
|
|
self._is_status_update_running = False
|
|
|
|
|
|
def cancel_download_before_retry(self, download_info):
|
|
|
try:
|
|
|
slskd_result = download_info.get('slskd_result')
|
|
|
if not slskd_result: return
|
|
|
download_id = download_info.get('download_id')
|
|
|
username = getattr(slskd_result, 'username', None)
|
|
|
if download_id and username:
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
try:
|
|
|
loop.run_until_complete(self.soulseek_client.cancel_download(download_id, username, remove=False))
|
|
|
finally:
|
|
|
loop.close()
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error cancelling download: {e}")
|
|
|
|
|
|
def retry_parallel_download_with_fallback(self, failed_download_info):
|
|
|
download_index = failed_download_info['download_index']
|
|
|
track_info = self.parallel_search_tracking[download_index]
|
|
|
track_info['retry_count'] += 1
|
|
|
if track_info['retry_count'] > 2:
|
|
|
self.on_parallel_track_failed(download_index, "All retries failed.")
|
|
|
return
|
|
|
candidates = failed_download_info.get('candidates', [])
|
|
|
used_sources = track_info.get('used_sources', set())
|
|
|
next_candidate = None
|
|
|
for candidate in candidates:
|
|
|
source_key = f"{getattr(candidate, 'username', 'unknown')}_{candidate.filename}"
|
|
|
if source_key not in used_sources:
|
|
|
next_candidate = candidate
|
|
|
break
|
|
|
if not next_candidate:
|
|
|
self.on_parallel_track_failed(download_index, "No alternative sources in cache")
|
|
|
return
|
|
|
self.track_table.setItem(failed_download_info['table_index'], 3, QTableWidgetItem(f"🔄 Retrying ({track_info['retry_count']})..."))
|
|
|
self.start_validated_download_parallel(next_candidate, track_info['spotify_track'], track_info['track_index'], track_info['table_index'], download_index)
|
|
|
|
|
|
def on_parallel_track_completed(self, download_index, success):
|
|
|
if not hasattr(self, 'parallel_search_tracking'):
|
|
|
print(f"⚠️ parallel_search_tracking not initialized yet, skipping completion for download {download_index}")
|
|
|
return
|
|
|
track_info = self.parallel_search_tracking.get(download_index)
|
|
|
if not track_info or track_info.get('completed', False): return
|
|
|
track_info['completed'] = True
|
|
|
if success:
|
|
|
self.track_table.setItem(track_info['table_index'], 3, QTableWidgetItem("✅ Downloaded"))
|
|
|
# Hide cancel button since track is now downloaded
|
|
|
self.hide_cancel_button_for_row(track_info['table_index'])
|
|
|
self.downloaded_tracks_count += 1
|
|
|
self.downloaded_count_label.setText(str(self.downloaded_tracks_count))
|
|
|
self.successful_downloads += 1
|
|
|
|
|
|
self.wishlist_service.remove_track_from_wishlist(track_info['spotify_track'].id)
|
|
|
|
|
|
|
|
|
logger.info(f"Successfully downloaded and removed '{track_info['spotify_track'].name}' from wishlist.")
|
|
|
else:
|
|
|
# Check if track was cancelled (don't overwrite cancelled status)
|
|
|
table_index = track_info['table_index']
|
|
|
current_status = self.track_table.item(table_index, 3)
|
|
|
if current_status and "🚫 Cancelled" in current_status.text():
|
|
|
print(f"🔧 Track {download_index} was cancelled - preserving cancelled status")
|
|
|
else:
|
|
|
self.track_table.setItem(table_index, 3, QTableWidgetItem("❌ Failed"))
|
|
|
if track_info not in self.permanently_failed_tracks:
|
|
|
self.permanently_failed_tracks.append(track_info)
|
|
|
self.failed_downloads += 1
|
|
|
self.update_failed_matches_button()
|
|
|
self.completed_downloads += 1
|
|
|
self.active_parallel_downloads -= 1
|
|
|
self.download_progress.setValue(self.completed_downloads)
|
|
|
self.start_next_batch_of_downloads()
|
|
|
|
|
|
def on_parallel_track_failed(self, download_index, reason):
|
|
|
logger.error(f"Parallel download {download_index + 1} failed: {reason}")
|
|
|
self.on_parallel_track_completed(download_index, False)
|
|
|
|
|
|
def update_failed_matches_button(self):
|
|
|
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):
|
|
|
if not self.permanently_failed_tracks: return
|
|
|
# This requires ManualMatchModal to be copied or imported
|
|
|
from ui.pages.sync import ManualMatchModal
|
|
|
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):
|
|
|
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)
|
|
|
self.update_failed_matches_button()
|
|
|
|
|
|
def on_all_downloads_complete(self):
|
|
|
self.download_in_progress = False
|
|
|
self._update_button_states()
|
|
|
self.parent_dashboard.auto_processing_wishlist = False
|
|
|
self.cancel_btn.hide()
|
|
|
self.process_finished.emit()
|
|
|
if self.successful_downloads > 0 and hasattr(self.parent_dashboard, 'scan_manager') and self.parent_dashboard.scan_manager:
|
|
|
self.parent_dashboard.scan_manager.request_scan(f"Wishlist download completed ({self.successful_downloads} tracks)")
|
|
|
|
|
|
# Add cancelled tracks that were missing from Plex to permanently_failed_tracks for wishlist re-addition
|
|
|
if hasattr(self, 'cancelled_tracks') and hasattr(self, 'missing_tracks'):
|
|
|
for cancelled_row in self.cancelled_tracks:
|
|
|
# Check if this cancelled track was actually missing from Plex
|
|
|
cancelled_track = self.wishlist_tracks[cancelled_row]
|
|
|
missing_track_result = None
|
|
|
|
|
|
# Find the corresponding missing track result
|
|
|
for missing_result in self.missing_tracks:
|
|
|
if missing_result.spotify_track.id == cancelled_track.id:
|
|
|
missing_track_result = missing_result
|
|
|
break
|
|
|
|
|
|
# Only add to wishlist if track was actually missing from Plex AND not successfully downloaded
|
|
|
if missing_track_result:
|
|
|
# Check if track was successfully downloaded (don't re-add downloaded tracks to wishlist)
|
|
|
status_item = self.track_table.item(cancelled_row, 3)
|
|
|
current_status = status_item.text() if status_item else ""
|
|
|
|
|
|
if "✅ Downloaded" in current_status:
|
|
|
print(f"🚫 Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist re-addition")
|
|
|
else:
|
|
|
cancelled_track_info = {
|
|
|
'download_index': cancelled_row,
|
|
|
'table_index': cancelled_row,
|
|
|
'track': cancelled_track,
|
|
|
'track_name': cancelled_track.name,
|
|
|
'artist_name': cancelled_track.artists[0] if cancelled_track.artists else "Unknown",
|
|
|
'retry_count': 0,
|
|
|
'spotify_track': missing_track_result.spotify_track # Include the spotify track for wishlist
|
|
|
}
|
|
|
# Check if not already in permanently_failed_tracks
|
|
|
if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks):
|
|
|
self.permanently_failed_tracks.append(cancelled_track_info)
|
|
|
print(f"🚫 Added cancelled missing track {cancelled_track.name} to failed list for wishlist re-addition")
|
|
|
else:
|
|
|
print(f"🚫 Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist re-addition")
|
|
|
|
|
|
wishlist_added_count = 0
|
|
|
if self.permanently_failed_tracks:
|
|
|
source_context = {'added_from': 'wishlist_modal', 'timestamp': datetime.now().isoformat()}
|
|
|
for failed_track_info in self.permanently_failed_tracks:
|
|
|
if self.wishlist_service.add_failed_track_from_modal(track_info=failed_track_info, source_type='wishlist', source_context=source_context):
|
|
|
wishlist_added_count += 1
|
|
|
|
|
|
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n"
|
|
|
if wishlist_added_count > 0:
|
|
|
final_message += f"✨ Re-added {wishlist_added_count} failed track{'s' if wishlist_added_count > 1 else ''} to wishlist for future retry.\n\n"
|
|
|
if self.permanently_failed_tracks:
|
|
|
final_message += "You can also manually correct failed downloads."
|
|
|
else:
|
|
|
final_message += "All tracks were downloaded successfully!"
|
|
|
logger.info("Wishlist processing complete. Scheduling next run in 10 minutes.")
|
|
|
self.parent_dashboard.wishlist_retry_timer.start(600000) # 10 minutes
|
|
|
# Removed success modal - users don't need to see completion notification
|
|
|
|
|
|
def on_cancel_clicked(self):
|
|
|
self.cancel_operations()
|
|
|
self._update_button_states()
|
|
|
self.process_finished.emit()
|
|
|
self.reject()
|
|
|
|
|
|
def on_clear_wishlist_clicked(self):
|
|
|
"""Handle Clear Wishlist button click with confirmation"""
|
|
|
# Don't allow clearing during active download
|
|
|
if self.download_in_progress:
|
|
|
return
|
|
|
|
|
|
# Show confirmation dialog
|
|
|
reply = QMessageBox.question(
|
|
|
self,
|
|
|
"Clear Wishlist",
|
|
|
"Are you sure you want to clear the entire wishlist?\n\n"
|
|
|
"This action cannot be undone and will permanently remove all wishlist tracks.",
|
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
|
QMessageBox.StandardButton.No
|
|
|
)
|
|
|
|
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
|
try:
|
|
|
# Clear the wishlist using the service
|
|
|
success = self.wishlist_service.clear_wishlist()
|
|
|
|
|
|
if success:
|
|
|
# Reset all UI elements
|
|
|
self._reset_ui_after_clear()
|
|
|
|
|
|
# Update dashboard wishlist button count
|
|
|
self.parent_dashboard.update_wishlist_button_count()
|
|
|
# Update dashboard watchlist button count
|
|
|
self.parent_dashboard.update_watchlist_button_count()
|
|
|
|
|
|
# Show success message
|
|
|
QMessageBox.information(
|
|
|
self,
|
|
|
"Wishlist Cleared",
|
|
|
"The wishlist has been successfully cleared."
|
|
|
)
|
|
|
|
|
|
logger.info("Wishlist cleared successfully by user")
|
|
|
|
|
|
else:
|
|
|
QMessageBox.critical(
|
|
|
self,
|
|
|
"Error",
|
|
|
"Failed to clear the wishlist. Please try again."
|
|
|
)
|
|
|
logger.error("Failed to clear wishlist")
|
|
|
|
|
|
except Exception as e:
|
|
|
QMessageBox.critical(
|
|
|
self,
|
|
|
"Error",
|
|
|
f"An error occurred while clearing the wishlist: {str(e)}"
|
|
|
)
|
|
|
logger.error(f"Error clearing wishlist: {e}")
|
|
|
|
|
|
def _reset_ui_after_clear(self):
|
|
|
"""Reset all UI elements after clearing the wishlist"""
|
|
|
# Reset counters
|
|
|
self.wishlist_tracks = []
|
|
|
self.total_tracks = 0
|
|
|
self.matched_tracks_count = 0
|
|
|
self.tracks_to_download_count = 0
|
|
|
self.downloaded_tracks_count = 0
|
|
|
self.analysis_complete = False
|
|
|
self.permanently_failed_tracks = []
|
|
|
self.analysis_results = []
|
|
|
self.missing_tracks = []
|
|
|
|
|
|
# Update counter labels
|
|
|
self.total_count_label.setText("0")
|
|
|
self.matched_count_label.setText("0")
|
|
|
self.download_count_label.setText("0")
|
|
|
self.downloaded_count_label.setText("0")
|
|
|
|
|
|
# Clear and reset track table
|
|
|
self.track_table.setRowCount(0)
|
|
|
|
|
|
# Reset progress bars
|
|
|
self.analysis_progress.setValue(0)
|
|
|
self.analysis_progress.setVisible(False)
|
|
|
self.download_progress.setValue(0)
|
|
|
self.download_progress.setVisible(False)
|
|
|
|
|
|
# Reset buttons to initial state
|
|
|
self.begin_search_btn.show()
|
|
|
self.cancel_btn.hide()
|
|
|
self.correct_failed_btn.hide()
|
|
|
|
|
|
# Update button state
|
|
|
self._update_button_states()
|
|
|
|
|
|
def _update_button_states(self):
|
|
|
"""Update button states based on current modal state"""
|
|
|
# Disable Clear Wishlist button during download operations
|
|
|
if self.download_in_progress:
|
|
|
self.clear_wishlist_btn.setEnabled(False)
|
|
|
self.clear_wishlist_btn.setStyleSheet(
|
|
|
"QPushButton { background-color: #666666; color: #999999; border-radius: 20px; font-size: 14px; font-weight: bold; }"
|
|
|
)
|
|
|
else:
|
|
|
# Enable only if there are tracks to clear
|
|
|
has_tracks = len(self.wishlist_tracks) > 0
|
|
|
self.clear_wishlist_btn.setEnabled(has_tracks)
|
|
|
if has_tracks:
|
|
|
self.clear_wishlist_btn.setStyleSheet(
|
|
|
"QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px; font-size: 14px; font-weight: bold; }"
|
|
|
)
|
|
|
else:
|
|
|
self.clear_wishlist_btn.setStyleSheet(
|
|
|
"QPushButton { background-color: #666666; color: #999999; border-radius: 20px; font-size: 14px; font-weight: bold; }"
|
|
|
)
|
|
|
|
|
|
def on_close_clicked(self):
|
|
|
if self.cancel_requested or not self.download_in_progress:
|
|
|
self.cancel_operations()
|
|
|
self.process_finished.emit()
|
|
|
self.reject()
|
|
|
|
|
|
def cancel_operations(self):
|
|
|
self.cancel_requested = True
|
|
|
for worker in self.active_workers:
|
|
|
if hasattr(worker, 'cancel'):
|
|
|
worker.cancel()
|
|
|
self.active_workers.clear()
|
|
|
self.download_status_timer.stop()
|
|
|
|
|
|
def closeEvent(self, event):
|
|
|
"""Override close event to hide the modal if a download is in progress."""
|
|
|
if self.download_in_progress and not self.cancel_requested:
|
|
|
# If a download is running, just hide the window.
|
|
|
# The user can bring it back by clicking the wishlist button again.
|
|
|
logger.info("Hiding wishlist modal while download is in progress.")
|
|
|
self.hide()
|
|
|
event.ignore()
|
|
|
else:
|
|
|
# If not downloading or cancelled, allow it to close for real.
|
|
|
logger.info("Closing wishlist modal.")
|
|
|
self.cancel_operations()
|
|
|
self.process_finished.emit()
|
|
|
event.accept()
|
|
|
|
|
|
class ParallelSearchWorker(QRunnable):
|
|
|
def __init__(self, soulseek_client, query):
|
|
|
super().__init__()
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.query = query
|
|
|
self.signals = self.create_signals()
|
|
|
def create_signals(self):
|
|
|
class Signals(QObject):
|
|
|
search_completed = pyqtSignal(list, str)
|
|
|
search_failed = pyqtSignal(str, str)
|
|
|
return Signals()
|
|
|
def run(self):
|
|
|
loop = None
|
|
|
try:
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
search_result = loop.run_until_complete(self.soulseek_client.search(self.query))
|
|
|
results_list = search_result[0] if isinstance(search_result, tuple) and search_result else []
|
|
|
|
|
|
# Check if signals object is still valid before emitting
|
|
|
try:
|
|
|
self.signals.search_completed.emit(results_list, self.query)
|
|
|
except RuntimeError:
|
|
|
# Qt objects deleted during shutdown, ignore
|
|
|
logger.debug(f"Search completed for '{self.query}' but UI already closed")
|
|
|
|
|
|
except Exception as e:
|
|
|
try:
|
|
|
self.signals.search_failed.emit(self.query, str(e))
|
|
|
except RuntimeError:
|
|
|
# Qt objects deleted during shutdown, ignore
|
|
|
logger.debug(f"Search failed for '{self.query}' but UI already closed: {e}")
|
|
|
finally:
|
|
|
if loop: loop.close()
|
|
|
|
|
|
def get_valid_candidates(self, results, spotify_track, query):
|
|
|
if not results: return []
|
|
|
initial_candidates = self.matching_engine.find_best_slskd_matches_enhanced(spotify_track, results)
|
|
|
if not initial_candidates: return []
|
|
|
verified_candidates = []
|
|
|
spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
|
normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower()
|
|
|
for candidate in initial_candidates:
|
|
|
normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', candidate.filename).lower()
|
|
|
if normalized_spotify_artist in normalized_slskd_path:
|
|
|
verified_candidates.append(candidate)
|
|
|
return verified_candidates
|
|
|
|
|
|
def create_spotify_based_search_result_from_validation(self, slskd_result, spotify_metadata):
|
|
|
class SpotifyBasedSearchResult:
|
|
|
def __init__(self):
|
|
|
self.filename = getattr(slskd_result, 'filename', f"{spotify_metadata.name}.flac")
|
|
|
self.username = getattr(slskd_result, 'username', 'unknown')
|
|
|
self.size = getattr(slskd_result, 'size', 0)
|
|
|
self.quality = getattr(slskd_result, 'quality', 'flac')
|
|
|
self.artist = spotify_metadata.artists[0] if spotify_metadata.artists else "Unknown"
|
|
|
self.title = spotify_metadata.name
|
|
|
self.album = spotify_metadata.album
|
|
|
return SpotifyBasedSearchResult()
|
|
|
|
|
|
def generate_smart_search_queries(self, artist_name, track_name):
|
|
|
class MockSpotifyTrack:
|
|
|
def __init__(self, name, artists, album=None):
|
|
|
self.name = name
|
|
|
self.artists = artists if isinstance(artists, list) else [artists] if artists else []
|
|
|
self.album = album
|
|
|
mock_track = MockSpotifyTrack(track_name, [artist_name] if artist_name else [], None)
|
|
|
queries = self.matching_engine.generate_download_queries(mock_track)
|
|
|
legacy_queries = [track_name.strip()]
|
|
|
if artist_name:
|
|
|
artist_words = artist_name.split()
|
|
|
if artist_words:
|
|
|
first_word = artist_words[0]
|
|
|
if first_word.lower() == 'the' and len(artist_words) > 1:
|
|
|
first_word = artist_words[1]
|
|
|
if len(first_word) > 1:
|
|
|
legacy_queries.append(f"{track_name} {first_word}".strip())
|
|
|
all_queries = queries + legacy_queries
|
|
|
unique_queries = list(dict.fromkeys(q for q in all_queries if q))
|
|
|
return unique_queries
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SimpleWishlistDownloadWorker(QRunnable):
|
|
|
"""Enhanced worker to download a single wishlist track with detailed status updates"""
|
|
|
|
|
|
class Signals(QObject):
|
|
|
status_updated = pyqtSignal(int, str) # download_index, status_text
|
|
|
download_completed = pyqtSignal(int, str) # download_index, download_id
|
|
|
download_failed = pyqtSignal(int, str) # download_index, error_message
|
|
|
|
|
|
def __init__(self, soulseek_client, query, track_data, download_index):
|
|
|
super().__init__()
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.query = query
|
|
|
self.track_data = track_data
|
|
|
self.download_index = download_index
|
|
|
self.signals = self.Signals()
|
|
|
|
|
|
def run(self):
|
|
|
"""Run the download with detailed status updates"""
|
|
|
try:
|
|
|
# Update status: Starting search
|
|
|
self.signals.status_updated.emit(self.download_index, "🔍 Searching...")
|
|
|
|
|
|
# Get quality preference
|
|
|
from config.settings import config_manager
|
|
|
quality_preference = config_manager.get_quality_preference()
|
|
|
|
|
|
# Use async method in sync context
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
|
try:
|
|
|
# Update status: Found candidates, analyzing
|
|
|
self.signals.status_updated.emit(self.download_index, "🔎 Analyzing results...")
|
|
|
|
|
|
# Use the enhanced search method that provides more feedback
|
|
|
results = loop.run_until_complete(
|
|
|
self._search_with_progress(self.query, quality_preference)
|
|
|
)
|
|
|
|
|
|
if results and len(results) > 0:
|
|
|
# Update status: Found candidates, starting download
|
|
|
self.signals.status_updated.emit(self.download_index, f"📋 Found {len(results)} candidates")
|
|
|
time.sleep(0.5) # Brief pause so user can see the status
|
|
|
|
|
|
# Get the best result and start download
|
|
|
best_result = results[0] # Assuming results are sorted by quality
|
|
|
|
|
|
self.signals.status_updated.emit(self.download_index, "⏬ Starting download...")
|
|
|
|
|
|
# Start the actual download
|
|
|
download_id = loop.run_until_complete(
|
|
|
self.soulseek_client.download_track(best_result)
|
|
|
)
|
|
|
|
|
|
if download_id:
|
|
|
self.signals.download_completed.emit(self.download_index, download_id)
|
|
|
else:
|
|
|
self.signals.download_failed.emit(self.download_index, "Download failed to start")
|
|
|
else:
|
|
|
self.signals.download_failed.emit(self.download_index, "No search results found")
|
|
|
|
|
|
finally:
|
|
|
loop.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
self.signals.download_failed.emit(self.download_index, str(e))
|
|
|
|
|
|
async def _search_with_progress(self, query, quality_preference):
|
|
|
"""Search for tracks with progress updates"""
|
|
|
try:
|
|
|
# Emit search progress
|
|
|
self.signals.status_updated.emit(self.download_index, "🌐 Searching network...")
|
|
|
|
|
|
# Perform the search (this would ideally use the soulseek client's search methods)
|
|
|
# For now, we'll use the existing search_and_download_best method
|
|
|
# but in a real implementation, you'd want to separate search from download
|
|
|
|
|
|
# This is a simplified version - in practice you'd want to:
|
|
|
# 1. Search for candidates
|
|
|
# 2. Filter by quality
|
|
|
# 3. Return the results for manual download
|
|
|
|
|
|
# For now, let's use a direct approach
|
|
|
from core.soulseek_client import SoulseekClient
|
|
|
if hasattr(self.soulseek_client, 'search_tracks'):
|
|
|
results = await self.soulseek_client.search_tracks(query)
|
|
|
|
|
|
if results:
|
|
|
# Filter by quality preference if needed
|
|
|
filtered_results = self.soulseek_client.filter_results_by_quality_preference(
|
|
|
results, quality_preference
|
|
|
)
|
|
|
return filtered_results
|
|
|
|
|
|
return []
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error in search with progress: {e}")
|
|
|
return []
|
|
|
|
|
|
|
|
|
class MetadataUpdateWorker(QThread):
|
|
|
"""Worker thread for updating Plex artist metadata using Spotify data"""
|
|
|
progress_updated = pyqtSignal(str, int, int, float) # current_artist, processed, total, percentage
|
|
|
artist_updated = pyqtSignal(str, bool, str) # artist_name, success, details
|
|
|
finished = pyqtSignal(int, int, int) # total_processed, successful, failed
|
|
|
error = pyqtSignal(str) # error_message
|
|
|
artists_loaded = pyqtSignal(int, int) # total_artists, artists_to_process
|
|
|
|
|
|
def __init__(self, artists, plex_client, spotify_client, refresh_interval_days=30):
|
|
|
super().__init__()
|
|
|
self.artists = artists
|
|
|
self.plex_client = plex_client
|
|
|
self.spotify_client = spotify_client
|
|
|
self.matching_engine = MusicMatchingEngine()
|
|
|
self.refresh_interval_days = refresh_interval_days
|
|
|
self.should_stop = False
|
|
|
self.processed_count = 0
|
|
|
self.successful_count = 0
|
|
|
self.failed_count = 0
|
|
|
self.max_workers = 4 # Same as your previous implementation
|
|
|
self.thread_lock = threading.Lock()
|
|
|
|
|
|
def stop(self):
|
|
|
self.should_stop = True
|
|
|
|
|
|
def run(self):
|
|
|
"""Process all artists one by one"""
|
|
|
try:
|
|
|
# Load artists in background if not provided
|
|
|
if self.artists is None:
|
|
|
all_artists = self.plex_client.get_all_artists()
|
|
|
if not all_artists:
|
|
|
self.error.emit("No artists found in Plex library")
|
|
|
return
|
|
|
|
|
|
# Filter artists that need processing
|
|
|
artists_to_process = [artist for artist in all_artists if self.artist_needs_processing(artist)]
|
|
|
self.artists = artists_to_process
|
|
|
|
|
|
# Emit loaded signal
|
|
|
self.artists_loaded.emit(len(all_artists), len(artists_to_process))
|
|
|
|
|
|
if not artists_to_process:
|
|
|
self.finished.emit(0, 0, 0)
|
|
|
return
|
|
|
|
|
|
total_artists = len(self.artists)
|
|
|
|
|
|
# Process artists in parallel using ThreadPoolExecutor
|
|
|
def process_single_artist(artist):
|
|
|
"""Process a single artist and return results"""
|
|
|
if self.should_stop:
|
|
|
return None
|
|
|
|
|
|
artist_name = getattr(artist, 'title', 'Unknown Artist')
|
|
|
|
|
|
# Double-check ignore flag right before processing (in case it was added after loading)
|
|
|
if self.plex_client.is_artist_ignored(artist):
|
|
|
return (artist_name, True, "Skipped (ignored)")
|
|
|
|
|
|
try:
|
|
|
success, details = self.update_artist_metadata(artist)
|
|
|
return (artist_name, success, details)
|
|
|
except Exception as e:
|
|
|
return (artist_name, False, f"Error: {str(e)}")
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
|
# Submit all tasks
|
|
|
future_to_artist = {executor.submit(process_single_artist, artist): artist
|
|
|
for artist in self.artists}
|
|
|
|
|
|
# Process completed tasks as they finish
|
|
|
for future in as_completed(future_to_artist):
|
|
|
if self.should_stop:
|
|
|
break
|
|
|
|
|
|
result = future.result()
|
|
|
if result is None: # Task was cancelled
|
|
|
continue
|
|
|
|
|
|
artist_name, success, details = result
|
|
|
|
|
|
with self.thread_lock:
|
|
|
self.processed_count += 1
|
|
|
if success:
|
|
|
self.successful_count += 1
|
|
|
else:
|
|
|
self.failed_count += 1
|
|
|
|
|
|
# Emit progress and result signals
|
|
|
progress_percent = (self.processed_count / total_artists) * 100
|
|
|
self.progress_updated.emit(artist_name, self.processed_count, total_artists, progress_percent)
|
|
|
self.artist_updated.emit(artist_name, success, details)
|
|
|
|
|
|
self.finished.emit(self.processed_count, self.successful_count, self.failed_count)
|
|
|
|
|
|
except Exception as e:
|
|
|
self.error.emit(f"Metadata update failed: {str(e)}")
|
|
|
|
|
|
def artist_needs_processing(self, artist):
|
|
|
"""Check if an artist needs metadata processing using age-based detection"""
|
|
|
try:
|
|
|
# Use PlexClient's age-based checking with configured interval
|
|
|
# This also handles the ignore flag check internally
|
|
|
return self.plex_client.needs_update_by_age(artist, self.refresh_interval_days)
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
|
|
return True # Process if we can't determine status
|
|
|
|
|
|
def update_artist_metadata(self, artist):
|
|
|
"""
|
|
|
Update a single artist's metadata by finding the best match on Spotify.
|
|
|
"""
|
|
|
try:
|
|
|
artist_name = getattr(artist, 'title', 'Unknown Artist')
|
|
|
|
|
|
# --- IMPROVED ARTIST MATCHING ---
|
|
|
# 1. Search for top 5 potential artists on Spotify
|
|
|
spotify_artists = self.spotify_client.search_artists(artist_name, limit=5)
|
|
|
if not spotify_artists:
|
|
|
return False, "Not found on Spotify"
|
|
|
|
|
|
# 2. Find the best match using the matching engine
|
|
|
best_match = None
|
|
|
highest_score = 0.0
|
|
|
|
|
|
plex_artist_normalized = self.matching_engine.normalize_string(artist_name)
|
|
|
|
|
|
for spotify_artist in spotify_artists:
|
|
|
spotify_artist_normalized = self.matching_engine.normalize_string(spotify_artist.name)
|
|
|
score = self.matching_engine.similarity_score(plex_artist_normalized, spotify_artist_normalized)
|
|
|
|
|
|
if score > highest_score:
|
|
|
highest_score = score
|
|
|
best_match = spotify_artist
|
|
|
|
|
|
# 3. If no suitable match is found, exit
|
|
|
if not best_match or highest_score < 0.7: # Confidence threshold
|
|
|
return False, f"No confident match found (best: '{getattr(best_match, 'name', 'N/A')}', score: {highest_score:.2f})"
|
|
|
|
|
|
spotify_artist = best_match
|
|
|
changes_made = []
|
|
|
|
|
|
# Update photo if needed
|
|
|
photo_updated = self.update_artist_photo(artist, spotify_artist)
|
|
|
if photo_updated:
|
|
|
changes_made.append("photo")
|
|
|
|
|
|
# Update genres
|
|
|
genres_updated = self.update_artist_genres(artist, spotify_artist)
|
|
|
if genres_updated:
|
|
|
changes_made.append("genres")
|
|
|
|
|
|
# Update album artwork
|
|
|
albums_updated = self.update_album_artwork(artist, spotify_artist)
|
|
|
if albums_updated > 0:
|
|
|
changes_made.append(f"{albums_updated} album art")
|
|
|
|
|
|
if changes_made:
|
|
|
# Update artist biography with timestamp to track last update
|
|
|
biography_updated = self.plex_client.update_artist_biography(artist)
|
|
|
if biography_updated:
|
|
|
changes_made.append("timestamp")
|
|
|
|
|
|
details = f"Updated {', '.join(changes_made)} (match: '{spotify_artist.name}', score: {highest_score:.2f})"
|
|
|
return True, details
|
|
|
else:
|
|
|
# Even if no metadata changes, update biography to record we checked this artist
|
|
|
self.plex_client.update_artist_biography(artist)
|
|
|
return True, "Already up to date"
|
|
|
|
|
|
except Exception as e:
|
|
|
return False, str(e)
|
|
|
|
|
|
def update_artist_photo(self, artist, spotify_artist):
|
|
|
"""Update artist photo from Spotify"""
|
|
|
try:
|
|
|
# Check if artist already has a good photo
|
|
|
if self.artist_has_valid_photo(artist):
|
|
|
return False
|
|
|
|
|
|
# Get the image URL from Spotify
|
|
|
if not spotify_artist.image_url:
|
|
|
return False
|
|
|
|
|
|
image_url = spotify_artist.image_url
|
|
|
|
|
|
# Download and validate image
|
|
|
response = requests.get(image_url, timeout=10)
|
|
|
response.raise_for_status()
|
|
|
|
|
|
# Validate and convert image
|
|
|
image_data = self.validate_and_convert_image(response.content)
|
|
|
if not image_data:
|
|
|
return False
|
|
|
|
|
|
# Upload to Plex
|
|
|
return self.upload_artist_poster(artist, image_data)
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"Error updating photo for {getattr(artist, 'title', 'Unknown')}: {e}")
|
|
|
return False
|
|
|
|
|
|
def update_artist_genres(self, artist, spotify_artist):
|
|
|
"""Update artist genres from Spotify and albums"""
|
|
|
try:
|
|
|
# Get existing genres
|
|
|
existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre)
|
|
|
for genre in (artist.genres or []))
|
|
|
|
|
|
# Get Spotify artist genres
|
|
|
spotify_genres = set(spotify_artist.genres or [])
|
|
|
|
|
|
# Get genres from all albums
|
|
|
album_genres = set()
|
|
|
try:
|
|
|
for album in artist.albums():
|
|
|
if hasattr(album, 'genres') and album.genres:
|
|
|
album_genres.update(genre.tag if hasattr(genre, 'tag') else str(genre)
|
|
|
for genre in album.genres)
|
|
|
except Exception:
|
|
|
pass # Albums might not be accessible
|
|
|
|
|
|
# Combine all genres (prioritize Spotify genres)
|
|
|
all_genres = spotify_genres.union(album_genres)
|
|
|
|
|
|
# Filter out empty/invalid genres
|
|
|
all_genres = {g for g in all_genres if g and g.strip() and len(g.strip()) > 1}
|
|
|
|
|
|
print(f"[DEBUG] Artist '{artist.title}': Existing={existing_genres}, Spotify={spotify_genres}, Albums={album_genres}, Combined={all_genres}")
|
|
|
|
|
|
# Only update if we have new genres and they're different
|
|
|
if all_genres and (not existing_genres or all_genres != existing_genres):
|
|
|
# Convert to list and limit to 10 genres
|
|
|
genre_list = list(all_genres)[:10]
|
|
|
|
|
|
print(f"[DEBUG] Updating genres for '{artist.title}' to: {genre_list}")
|
|
|
|
|
|
# Use Plex API to update genres
|
|
|
success = self.plex_client.update_artist_genres(artist, genre_list)
|
|
|
if success:
|
|
|
print(f"[DEBUG] Successfully updated genres for '{artist.title}'")
|
|
|
return True
|
|
|
else:
|
|
|
print(f"[DEBUG] Failed to update genres for '{artist.title}'")
|
|
|
return False
|
|
|
else:
|
|
|
print(f"[DEBUG] No genre update needed for '{artist.title}' - already has good genres")
|
|
|
return False
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"Error updating genres for {getattr(artist, 'title', 'Unknown')}: {e}")
|
|
|
return False
|
|
|
|
|
|
def update_album_artwork(self, artist, spotify_artist):
|
|
|
"""Update album artwork for all albums by this artist"""
|
|
|
try:
|
|
|
updated_count = 0
|
|
|
skipped_count = 0
|
|
|
|
|
|
# Get all albums for this artist
|
|
|
try:
|
|
|
albums = list(artist.albums())
|
|
|
except Exception:
|
|
|
print(f"Could not access albums for artist '{artist.title}'")
|
|
|
return 0
|
|
|
|
|
|
if not albums:
|
|
|
print(f"No albums found for artist '{artist.title}'")
|
|
|
return 0
|
|
|
|
|
|
print(f"🎨 Checking artwork for {len(albums)} albums by '{artist.title}'...")
|
|
|
|
|
|
for album in albums:
|
|
|
try:
|
|
|
album_title = getattr(album, 'title', 'Unknown Album')
|
|
|
|
|
|
# Check if album already has good artwork (debug=True to see detection logic)
|
|
|
if self.album_has_valid_artwork(album, debug=True):
|
|
|
skipped_count += 1
|
|
|
continue
|
|
|
|
|
|
print(f"Album '{album_title}' needs artwork - searching Spotify...")
|
|
|
|
|
|
# Search for this specific album on Spotify
|
|
|
album_query = f"album:{album_title} artist:{spotify_artist.name}"
|
|
|
spotify_albums = self.spotify_client.search_albums(album_query, limit=3)
|
|
|
|
|
|
if not spotify_albums:
|
|
|
print(f"No Spotify results for album '{album_title}'")
|
|
|
continue
|
|
|
|
|
|
# Find the best matching album
|
|
|
best_album = None
|
|
|
highest_score = 0.0
|
|
|
|
|
|
plex_album_normalized = self.matching_engine.normalize_string(album_title)
|
|
|
|
|
|
for spotify_album in spotify_albums:
|
|
|
spotify_album_normalized = self.matching_engine.normalize_string(spotify_album.name)
|
|
|
score = self.matching_engine.similarity_score(plex_album_normalized, spotify_album_normalized)
|
|
|
|
|
|
if score > highest_score:
|
|
|
highest_score = score
|
|
|
best_album = spotify_album
|
|
|
|
|
|
# If we found a good match with artwork, download it
|
|
|
if best_album and highest_score > 0.7 and best_album.image_url:
|
|
|
print(f"Found Spotify match: '{best_album.name}' (score: {highest_score:.2f})")
|
|
|
|
|
|
# Download and upload the artwork
|
|
|
if self.download_and_upload_album_artwork(album, best_album.image_url):
|
|
|
updated_count += 1
|
|
|
|
|
|
else:
|
|
|
print(f"No good Spotify match for album '{album_title}' (best score: {highest_score:.2f})")
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"Error processing album '{getattr(album, 'title', 'Unknown')}': {e}")
|
|
|
continue
|
|
|
|
|
|
total_processed = updated_count + skipped_count
|
|
|
print(f"🎨 Artwork summary for '{artist.title}': {updated_count} updated, {skipped_count} skipped (already have good artwork)")
|
|
|
|
|
|
if updated_count == 0 and skipped_count == len(albums):
|
|
|
print(f" ✅ All albums already have good artwork - no Spotify API calls needed!")
|
|
|
return updated_count
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"Error updating album artwork for artist '{getattr(artist, 'title', 'Unknown')}': {e}")
|
|
|
return 0
|
|
|
|
|
|
def album_has_valid_artwork(self, album, debug=False):
|
|
|
"""Check if album has valid artwork - conservative approach"""
|
|
|
try:
|
|
|
album_title = getattr(album, 'title', 'Unknown Album')
|
|
|
|
|
|
# Check if album has any thumb at all
|
|
|
if not hasattr(album, 'thumb') or not album.thumb:
|
|
|
if debug: print(f" 🎨 Album '{album_title}' has NO THUMB - needs update")
|
|
|
return False
|
|
|
|
|
|
thumb_url = str(album.thumb)
|
|
|
if debug: print(f" 🔍 Album '{album_title}' artwork URL: {thumb_url}")
|
|
|
|
|
|
# CONSERVATIVE APPROACH: Only mark as "needs update" in very obvious cases
|
|
|
|
|
|
# Case 1: Completely empty or None
|
|
|
if not thumb_url or thumb_url.strip() == '':
|
|
|
if debug: print(f" 🎨 Album '{album_title}' has empty URL - needs update")
|
|
|
return False
|
|
|
|
|
|
# Case 2: Obvious placeholder text in URL
|
|
|
obvious_placeholders = [
|
|
|
'no-image',
|
|
|
'placeholder',
|
|
|
'missing',
|
|
|
'default-album',
|
|
|
'blank.jpg',
|
|
|
'empty.png'
|
|
|
]
|
|
|
|
|
|
thumb_lower = thumb_url.lower()
|
|
|
for placeholder in obvious_placeholders:
|
|
|
if placeholder in thumb_lower:
|
|
|
if debug: print(f" 🎨 Album '{album_title}' has obvious placeholder ({placeholder}) - needs update")
|
|
|
return False
|
|
|
|
|
|
# Case 3: Extremely short URLs (likely broken)
|
|
|
if len(thumb_url) < 20:
|
|
|
if debug: print(f" 🎨 Album '{album_title}' has very short URL ({len(thumb_url)} chars) - needs update")
|
|
|
return False
|
|
|
|
|
|
# OTHERWISE: Assume it has valid artwork and SKIP updating
|
|
|
if debug: print(f" ✅ Album '{album_title}' appears to have artwork - SKIPPING (URL: {len(thumb_url)} chars)")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
if debug: print(f" ❌ Error checking artwork for album '{album_title}': {e}")
|
|
|
# If we can't check, be conservative and skip updating
|
|
|
return True
|
|
|
|
|
|
def download_and_upload_album_artwork(self, album, image_url):
|
|
|
"""Download artwork from Spotify and upload to Plex"""
|
|
|
try:
|
|
|
album_title = getattr(album, 'title', 'Unknown Album')
|
|
|
|
|
|
# Download image from Spotify
|
|
|
response = requests.get(image_url, timeout=10)
|
|
|
response.raise_for_status()
|
|
|
|
|
|
# Validate and convert image (reuse existing function)
|
|
|
image_data = self.validate_and_convert_image(response.content)
|
|
|
if not image_data:
|
|
|
print(f"Invalid image data for album '{album_title}'")
|
|
|
return False
|
|
|
|
|
|
# Upload to Plex using our new method
|
|
|
success = self.plex_client.update_album_poster(album, image_data)
|
|
|
if success:
|
|
|
print(f"✅ Updated artwork for album '{album_title}'")
|
|
|
else:
|
|
|
print(f"❌ Failed to upload artwork for album '{album_title}'")
|
|
|
|
|
|
return success
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"Error downloading/uploading artwork for album '{getattr(album, 'title', 'Unknown')}': {e}")
|
|
|
return False
|
|
|
|
|
|
def artist_has_valid_photo(self, artist):
|
|
|
"""Check if artist has a valid photo"""
|
|
|
try:
|
|
|
if not hasattr(artist, 'thumb') or not artist.thumb:
|
|
|
return False
|
|
|
|
|
|
thumb_url = str(artist.thumb)
|
|
|
if 'default' in thumb_url.lower() or len(thumb_url) < 50:
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
return False
|
|
|
|
|
|
def validate_and_convert_image(self, image_data):
|
|
|
"""Validate and convert image for Plex compatibility"""
|
|
|
try:
|
|
|
# Open and validate image
|
|
|
image = Image.open(io.BytesIO(image_data))
|
|
|
|
|
|
# Check minimum dimensions
|
|
|
width, height = image.size
|
|
|
if width < 200 or height < 200:
|
|
|
return None
|
|
|
|
|
|
# Convert to JPEG for consistency
|
|
|
if image.format != 'JPEG':
|
|
|
buffer = io.BytesIO()
|
|
|
image.convert('RGB').save(buffer, format='JPEG', quality=95)
|
|
|
return buffer.getvalue()
|
|
|
|
|
|
return image_data
|
|
|
|
|
|
except Exception:
|
|
|
return None
|
|
|
|
|
|
def upload_artist_poster(self, artist, image_data):
|
|
|
"""Upload poster to Plex"""
|
|
|
try:
|
|
|
# Use Plex client's update method if available
|
|
|
if hasattr(self.plex_client, 'update_artist_poster'):
|
|
|
return self.plex_client.update_artist_poster(artist, image_data)
|
|
|
|
|
|
# Fallback: direct Plex API call
|
|
|
server = self.plex_client.server
|
|
|
upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters"
|
|
|
headers = {
|
|
|
'X-Plex-Token': server._token,
|
|
|
'Content-Type': 'image/jpeg'
|
|
|
}
|
|
|
|
|
|
response = requests.post(upload_url, data=image_data, headers=headers)
|
|
|
response.raise_for_status()
|
|
|
|
|
|
# Refresh artist to see changes
|
|
|
artist.refresh()
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"Error uploading poster: {e}")
|
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
|
class ServiceStatus:
|
|
|
name: str
|
|
|
connected: bool
|
|
|
last_check: datetime
|
|
|
response_time: float = 0.0
|
|
|
error: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
|
class DownloadStats:
|
|
|
active_count: int = 0
|
|
|
finished_count: int = 0
|
|
|
total_speed: float = 0.0
|
|
|
total_transferred: int = 0
|
|
|
|
|
|
@dataclass
|
|
|
class MetadataProgress:
|
|
|
is_running: bool = False
|
|
|
current_artist: str = ""
|
|
|
processed_count: int = 0
|
|
|
total_count: int = 0
|
|
|
progress_percentage: float = 0.0
|
|
|
|
|
|
class DashboardDataProvider(QObject):
|
|
|
# Signals for real-time updates
|
|
|
service_status_updated = pyqtSignal(str, bool, float, str) # service, connected, response_time, error
|
|
|
download_stats_updated = pyqtSignal(int, int, float) # active, finished, speed
|
|
|
metadata_progress_updated = pyqtSignal(bool, str, int, int, float) # running, artist, processed, total, percentage
|
|
|
sync_progress_updated = pyqtSignal(str, int) # current_playlist, progress
|
|
|
system_stats_updated = pyqtSignal(str, str) # uptime, memory
|
|
|
activity_item_added = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
|
|
|
|
|
|
def __init__(self, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self.service_clients = {}
|
|
|
self.downloads_page = None
|
|
|
self.sync_page = None
|
|
|
self.app_start_time = None
|
|
|
|
|
|
# Data storage
|
|
|
self.service_status = {
|
|
|
'spotify': ServiceStatus('Spotify', False, datetime.now()),
|
|
|
'plex': ServiceStatus('Plex', False, datetime.now()),
|
|
|
'soulseek': ServiceStatus('Soulseek', False, datetime.now())
|
|
|
}
|
|
|
self.download_stats = DownloadStats()
|
|
|
self.metadata_progress = MetadataProgress()
|
|
|
|
|
|
# Session-based counters (reset on app restart)
|
|
|
self.session_completed_downloads = 0
|
|
|
|
|
|
# Update timers with different frequencies
|
|
|
self.download_stats_timer = QTimer()
|
|
|
self.download_stats_timer.timeout.connect(self.update_download_stats)
|
|
|
self.download_stats_timer.start(2000) # Update every 2 seconds
|
|
|
|
|
|
self.system_stats_timer = QTimer()
|
|
|
self.system_stats_timer.timeout.connect(self.update_system_stats)
|
|
|
self.system_stats_timer.start(10000) # Update every 10 seconds
|
|
|
|
|
|
def set_service_clients(self, spotify_client, plex_client, soulseek_client):
|
|
|
self.service_clients = {
|
|
|
'spotify_client': spotify_client,
|
|
|
'plex_client': plex_client,
|
|
|
'soulseek_client': soulseek_client
|
|
|
}
|
|
|
|
|
|
def set_page_references(self, downloads_page, sync_page):
|
|
|
self.downloads_page = downloads_page
|
|
|
self.sync_page = sync_page
|
|
|
|
|
|
def set_app_start_time(self, start_time):
|
|
|
self.app_start_time = start_time
|
|
|
|
|
|
def increment_completed_downloads(self, title="Unknown Track", artist="Unknown Artist"):
|
|
|
"""Increment the session completed downloads counter"""
|
|
|
self.session_completed_downloads += 1
|
|
|
|
|
|
# Emit signal for activity feed with specific track info
|
|
|
self.activity_item_added.emit("📥", "Download Complete", f"'{title}' by {artist}", "Now")
|
|
|
|
|
|
def update_service_status(self, service: str, connected: bool, response_time: float = 0.0, error: str = ""):
|
|
|
if service in self.service_status:
|
|
|
self.service_status[service].connected = connected
|
|
|
self.service_status[service].last_check = datetime.now()
|
|
|
self.service_status[service].response_time = response_time
|
|
|
self.service_status[service].error = error
|
|
|
self.service_status_updated.emit(service, connected, response_time, error)
|
|
|
|
|
|
def update_download_stats(self):
|
|
|
if self.downloads_page and hasattr(self.downloads_page, 'download_queue'):
|
|
|
try:
|
|
|
active_count = len(self.downloads_page.download_queue.active_queue.download_items)
|
|
|
finished_count = len(self.downloads_page.download_queue.finished_queue.download_items)
|
|
|
|
|
|
# Calculate total speed from active downloads (in bytes/sec)
|
|
|
total_speed = 0.0
|
|
|
for item in self.downloads_page.download_queue.active_queue.download_items:
|
|
|
if hasattr(item, 'download_speed') and isinstance(item.download_speed, (int, float)) and item.download_speed > 0:
|
|
|
# download_speed is already in bytes/sec from slskd API
|
|
|
total_speed += float(item.download_speed)
|
|
|
|
|
|
self.download_stats.active_count = active_count
|
|
|
self.download_stats.finished_count = self.session_completed_downloads # Use session counter
|
|
|
self.download_stats.total_speed = total_speed
|
|
|
|
|
|
self.download_stats_updated.emit(active_count, self.session_completed_downloads, total_speed)
|
|
|
except Exception as e:
|
|
|
pass # Silent failure for stats updates
|
|
|
|
|
|
# Update sync stats
|
|
|
if self.sync_page and hasattr(self.sync_page, 'active_sync_workers'):
|
|
|
try:
|
|
|
active_syncs = len(self.sync_page.active_sync_workers)
|
|
|
self.sync_progress_updated.emit("", active_syncs)
|
|
|
except Exception as e:
|
|
|
pass # Silent failure for stats updates
|
|
|
|
|
|
def update_system_stats(self):
|
|
|
"""Update system statistics (uptime and memory)"""
|
|
|
try:
|
|
|
uptime_str = self.get_uptime_string()
|
|
|
memory_str = self.get_memory_usage()
|
|
|
self.system_stats_updated.emit(uptime_str, memory_str)
|
|
|
except Exception as e:
|
|
|
pass
|
|
|
|
|
|
def get_uptime_string(self):
|
|
|
"""Get formatted uptime string"""
|
|
|
if not self.app_start_time:
|
|
|
return "Unknown"
|
|
|
|
|
|
try:
|
|
|
uptime_seconds = time.time() - self.app_start_time
|
|
|
|
|
|
if uptime_seconds < 60:
|
|
|
return f"{int(uptime_seconds)}s"
|
|
|
elif uptime_seconds < 3600:
|
|
|
minutes = int(uptime_seconds / 60)
|
|
|
return f"{minutes}m"
|
|
|
elif uptime_seconds < 86400:
|
|
|
hours = int(uptime_seconds / 3600)
|
|
|
minutes = int((uptime_seconds % 3600) / 60)
|
|
|
return f"{hours}h {minutes}m"
|
|
|
else:
|
|
|
days = int(uptime_seconds / 86400)
|
|
|
hours = int((uptime_seconds % 86400) / 3600)
|
|
|
return f"{days}d {hours}h"
|
|
|
except Exception:
|
|
|
return "Unknown"
|
|
|
|
|
|
def get_memory_usage(self):
|
|
|
"""Get formatted memory usage string"""
|
|
|
try:
|
|
|
# Try using resource module first (Unix-like systems)
|
|
|
if HAS_RESOURCE and hasattr(resource, 'RUSAGE_SELF'):
|
|
|
usage = resource.getrusage(resource.RUSAGE_SELF)
|
|
|
# ru_maxrss is in KB on Linux, bytes on macOS
|
|
|
max_rss = usage.ru_maxrss
|
|
|
|
|
|
# Detect platform and convert accordingly
|
|
|
import platform
|
|
|
if platform.system() == 'Darwin': # macOS
|
|
|
memory_mb = max_rss / (1024 * 1024)
|
|
|
else: # Linux
|
|
|
memory_mb = max_rss / 1024
|
|
|
|
|
|
return f"~{memory_mb:.0f} MB"
|
|
|
|
|
|
# Windows fallback: try psutil if available
|
|
|
try:
|
|
|
import psutil
|
|
|
process = psutil.Process(os.getpid())
|
|
|
memory_mb = process.memory_info().rss / (1024 * 1024)
|
|
|
return f"~{memory_mb:.0f} MB"
|
|
|
except ImportError:
|
|
|
pass
|
|
|
|
|
|
# Linux fallback: try reading /proc/self/status
|
|
|
if os.path.exists('/proc/self/status'):
|
|
|
with open('/proc/self/status', 'r') as f:
|
|
|
for line in f:
|
|
|
if line.startswith('VmRSS:'):
|
|
|
kb = int(line.split()[1])
|
|
|
return f"~{kb / 1024:.0f} MB"
|
|
|
|
|
|
return "N/A"
|
|
|
except Exception:
|
|
|
return "N/A"
|
|
|
|
|
|
def test_service_connection(self, service: str):
|
|
|
"""Test connection to a specific service"""
|
|
|
print(f"DEBUG: Testing {service} connection")
|
|
|
print(f"DEBUG: Available service clients: {list(self.service_clients.keys())}")
|
|
|
|
|
|
# Map service names to client keys
|
|
|
service_key_map = {
|
|
|
'spotify': 'spotify_client',
|
|
|
'plex': 'plex_client',
|
|
|
'soulseek': 'soulseek_client'
|
|
|
}
|
|
|
|
|
|
client_key = service_key_map.get(service, service)
|
|
|
if client_key not in self.service_clients:
|
|
|
print(f"DEBUG: Service {service} (key: {client_key}) not found in service_clients")
|
|
|
return
|
|
|
|
|
|
print(f"DEBUG: Service client for {service}: {self.service_clients[client_key]}")
|
|
|
|
|
|
# Clean up any existing test thread for this service
|
|
|
if hasattr(self, '_test_threads') and service in self._test_threads:
|
|
|
old_thread = self._test_threads[service]
|
|
|
if old_thread.isRunning():
|
|
|
old_thread.quit()
|
|
|
old_thread.wait()
|
|
|
old_thread.deleteLater()
|
|
|
|
|
|
# Initialize test threads dict if needed
|
|
|
if not hasattr(self, '_test_threads'):
|
|
|
self._test_threads = {}
|
|
|
|
|
|
# Run connection test in background thread
|
|
|
test_thread = ServiceTestThread(service, self.service_clients[client_key])
|
|
|
test_thread.test_completed.connect(self.on_service_test_completed)
|
|
|
test_thread.finished.connect(lambda: self._cleanup_test_thread(service))
|
|
|
self._test_threads[service] = test_thread
|
|
|
print(f"DEBUG: Starting test thread for {service}")
|
|
|
test_thread.start()
|
|
|
|
|
|
def _cleanup_test_thread(self, service: str):
|
|
|
"""Clean up completed test thread"""
|
|
|
if hasattr(self, '_test_threads') and service in self._test_threads:
|
|
|
thread = self._test_threads[service]
|
|
|
if thread.isRunning():
|
|
|
thread.quit()
|
|
|
thread.wait(1000) # Wait up to 1 second
|
|
|
thread.deleteLater()
|
|
|
del self._test_threads[service]
|
|
|
|
|
|
def on_service_test_completed(self, service: str, connected: bool, response_time: float, error: str):
|
|
|
self.update_service_status(service, connected, response_time, error)
|
|
|
|
|
|
class ServiceTestThread(QThread):
|
|
|
test_completed = pyqtSignal(str, bool, float, str) # service, connected, response_time, error
|
|
|
|
|
|
def __init__(self, service: str, client, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self.service = service
|
|
|
self.client = client
|
|
|
|
|
|
def run(self):
|
|
|
start_time = time.time()
|
|
|
connected = False
|
|
|
error = ""
|
|
|
|
|
|
try:
|
|
|
if self.service == 'spotify':
|
|
|
connected = self.client.is_authenticated()
|
|
|
elif self.service == 'plex':
|
|
|
connected = self.client.is_connected()
|
|
|
elif self.service == 'soulseek':
|
|
|
# Run async method in new event loop
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
try:
|
|
|
connected = loop.run_until_complete(self.client.check_connection())
|
|
|
finally:
|
|
|
loop.close()
|
|
|
except Exception as e:
|
|
|
error = str(e)
|
|
|
connected = False
|
|
|
|
|
|
response_time = (time.time() - start_time) * 1000 # Convert to milliseconds
|
|
|
self.test_completed.emit(self.service, connected, response_time, error)
|
|
|
|
|
|
# Ensure thread finishes properly
|
|
|
self.quit()
|
|
|
|
|
|
class StatCard(QFrame):
|
|
|
def __init__(self, title: str, value: str, subtitle: str = "", clickable: bool = False, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self.clickable = clickable
|
|
|
self.title_text = title
|
|
|
self.setup_ui(title, value, subtitle)
|
|
|
|
|
|
def setup_ui(self, title: str, value: str, subtitle: str):
|
|
|
self.setFixedHeight(120)
|
|
|
hover_style = "border: 1px solid #1db954;" if self.clickable else ""
|
|
|
self.setStyleSheet(f"""
|
|
|
StatCard {{
|
|
|
background: #282828;
|
|
|
border-radius: 8px;
|
|
|
border: 1px solid #404040;
|
|
|
}}
|
|
|
StatCard:hover {{
|
|
|
background: #333333;
|
|
|
{hover_style}
|
|
|
}}
|
|
|
""")
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
layout.setContentsMargins(20, 15, 20, 15)
|
|
|
layout.setSpacing(5)
|
|
|
|
|
|
# Title
|
|
|
self.title_label = QLabel(title)
|
|
|
self.title_label.setFont(QFont("Arial", 10))
|
|
|
self.title_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
|
|
# Value
|
|
|
self.value_label = QLabel(value)
|
|
|
self.value_label.setFont(QFont("Arial", 24, QFont.Weight.Bold))
|
|
|
self.value_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
# Subtitle
|
|
|
self.subtitle_label = None
|
|
|
if subtitle:
|
|
|
self.subtitle_label = QLabel(subtitle)
|
|
|
self.subtitle_label.setFont(QFont("Arial", 9))
|
|
|
self.subtitle_label.setStyleSheet("color: #b3b3b3;")
|
|
|
layout.addWidget(self.subtitle_label)
|
|
|
|
|
|
layout.addWidget(self.title_label)
|
|
|
layout.addWidget(self.value_label)
|
|
|
layout.addStretch()
|
|
|
|
|
|
def update_values(self, value: str, subtitle: str = ""):
|
|
|
self.value_label.setText(value)
|
|
|
if self.subtitle_label and subtitle:
|
|
|
self.subtitle_label.setText(subtitle)
|
|
|
|
|
|
def mousePressEvent(self, event):
|
|
|
if self.clickable:
|
|
|
self.parent().on_stat_card_clicked(self.title_text)
|
|
|
super().mousePressEvent(event)
|
|
|
|
|
|
class ServiceStatusCard(QFrame):
|
|
|
def __init__(self, service_name: str, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self.service_name = service_name
|
|
|
self.setup_ui()
|
|
|
|
|
|
def setup_ui(self):
|
|
|
self.setFixedHeight(140)
|
|
|
self.setStyleSheet("""
|
|
|
ServiceStatusCard {
|
|
|
background: #282828;
|
|
|
border-radius: 8px;
|
|
|
border: 1px solid #404040;
|
|
|
}
|
|
|
ServiceStatusCard:hover {
|
|
|
background: #333333;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
layout.setContentsMargins(15, 12, 15, 12)
|
|
|
layout.setSpacing(8)
|
|
|
|
|
|
# Header with service name and status indicator
|
|
|
header_layout = QHBoxLayout()
|
|
|
header_layout.setSpacing(10)
|
|
|
|
|
|
self.service_label = QLabel(self.service_name)
|
|
|
self.service_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
|
|
self.service_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
self.status_indicator = QLabel("●")
|
|
|
self.status_indicator.setFont(QFont("Arial", 16))
|
|
|
self.status_indicator.setStyleSheet("color: #ff4444;") # Red by default
|
|
|
|
|
|
header_layout.addWidget(self.service_label)
|
|
|
header_layout.addStretch()
|
|
|
header_layout.addWidget(self.status_indicator)
|
|
|
|
|
|
# Status details
|
|
|
self.status_text = QLabel("Disconnected")
|
|
|
self.status_text.setFont(QFont("Arial", 9))
|
|
|
self.status_text.setStyleSheet("color: #b3b3b3;")
|
|
|
|
|
|
self.response_time_label = QLabel("Response: --")
|
|
|
self.response_time_label.setFont(QFont("Arial", 8))
|
|
|
self.response_time_label.setStyleSheet("color: #888888;")
|
|
|
|
|
|
# Test connection button
|
|
|
self.test_button = QPushButton("Test Connection")
|
|
|
self.test_button.setFixedHeight(24)
|
|
|
self.test_button.setFont(QFont("Arial", 8))
|
|
|
self.test_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #1db954;
|
|
|
color: white;
|
|
|
border: none;
|
|
|
border-radius: 4px;
|
|
|
padding: 4px 8px;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #1ed760;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #169c46;
|
|
|
}
|
|
|
QPushButton:disabled {
|
|
|
background: #555555;
|
|
|
color: #999999;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
layout.addLayout(header_layout)
|
|
|
layout.addWidget(self.status_text)
|
|
|
layout.addWidget(self.response_time_label)
|
|
|
layout.addStretch()
|
|
|
layout.addWidget(self.test_button)
|
|
|
|
|
|
def update_status(self, connected: bool, response_time: float = 0.0, error: str = ""):
|
|
|
if connected:
|
|
|
self.status_indicator.setStyleSheet("color: #1db954;") # Green
|
|
|
self.status_text.setText("Connected")
|
|
|
self.response_time_label.setText(f"Response: {response_time:.0f}ms")
|
|
|
else:
|
|
|
self.status_indicator.setStyleSheet("color: #ff4444;") # Red
|
|
|
self.status_text.setText("Disconnected")
|
|
|
if error:
|
|
|
self.status_text.setText(f"Error: {error[:30]}..." if len(error) > 30 else f"Error: {error}")
|
|
|
self.response_time_label.setText("Response: --")
|
|
|
|
|
|
# Brief visual feedback
|
|
|
self.test_button.setText("Testing..." if not connected and error == "" else "Test Connection")
|
|
|
self.test_button.setEnabled(True)
|
|
|
|
|
|
class MetadataUpdaterWidget(QFrame):
|
|
|
def __init__(self, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self.setup_ui()
|
|
|
|
|
|
def setup_ui(self):
|
|
|
self.setStyleSheet("""
|
|
|
MetadataUpdaterWidget {
|
|
|
background: #282828;
|
|
|
border-radius: 8px;
|
|
|
border: 1px solid #404040;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
layout.setContentsMargins(20, 15, 20, 15)
|
|
|
layout.setSpacing(12)
|
|
|
|
|
|
# Header
|
|
|
header_label = QLabel("Plex Metadata Updater")
|
|
|
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
|
|
header_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
# Info label
|
|
|
info_label = QLabel("(type -IgnoreUpdate into artist summary to ignore metadata updates on this artist)")
|
|
|
info_label.setFont(QFont("Arial", 9))
|
|
|
info_label.setStyleSheet("color: #b3b3b3; margin-bottom: 5px;")
|
|
|
|
|
|
# Control section - reorganized for better balance
|
|
|
control_layout = QVBoxLayout()
|
|
|
control_layout.setSpacing(12)
|
|
|
|
|
|
# Top row: Button
|
|
|
button_layout = QHBoxLayout()
|
|
|
self.start_button = QPushButton("Begin Metadata Update")
|
|
|
self.start_button.setFixedHeight(36)
|
|
|
self.start_button.setFont(QFont("Arial", 10, QFont.Weight.Medium))
|
|
|
self.start_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #1db954;
|
|
|
color: white;
|
|
|
border: none;
|
|
|
border-radius: 6px;
|
|
|
padding: 8px 16px;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #1ed760;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #169c46;
|
|
|
}
|
|
|
QPushButton:disabled {
|
|
|
background: #555555;
|
|
|
color: #999999;
|
|
|
}
|
|
|
""")
|
|
|
button_layout.addWidget(self.start_button)
|
|
|
button_layout.addStretch()
|
|
|
|
|
|
# Bottom row: Settings and status
|
|
|
settings_layout = QHBoxLayout()
|
|
|
settings_layout.setSpacing(25)
|
|
|
|
|
|
# Refresh interval dropdown
|
|
|
refresh_info_layout = QVBoxLayout()
|
|
|
refresh_info_layout.setSpacing(4)
|
|
|
|
|
|
refresh_label = QLabel("Refresh Interval:")
|
|
|
refresh_label.setFont(QFont("Arial", 9))
|
|
|
refresh_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
|
|
self.refresh_interval_combo = QComboBox()
|
|
|
self.refresh_interval_combo.setFixedHeight(32)
|
|
|
self.refresh_interval_combo.setFont(QFont("Arial", 10))
|
|
|
self.refresh_interval_combo.addItems([
|
|
|
"6 months",
|
|
|
"3 months",
|
|
|
"1 month",
|
|
|
"2 weeks",
|
|
|
"1 week",
|
|
|
"Full refresh"
|
|
|
])
|
|
|
self.refresh_interval_combo.setCurrentText("1 month") # Default selection
|
|
|
self.refresh_interval_combo.setStyleSheet("""
|
|
|
QComboBox {
|
|
|
background: #333333;
|
|
|
color: #ffffff;
|
|
|
border: 1px solid #555555;
|
|
|
border-radius: 4px;
|
|
|
padding: 4px 8px;
|
|
|
min-width: 120px;
|
|
|
}
|
|
|
QComboBox:hover {
|
|
|
border: 1px solid #1db954;
|
|
|
}
|
|
|
QComboBox::drop-down {
|
|
|
border: none;
|
|
|
width: 20px;
|
|
|
}
|
|
|
QComboBox::down-arrow {
|
|
|
image: none;
|
|
|
border-left: 5px solid transparent;
|
|
|
border-right: 5px solid transparent;
|
|
|
border-top: 5px solid #ffffff;
|
|
|
margin-right: 5px;
|
|
|
}
|
|
|
QComboBox QAbstractItemView {
|
|
|
background: #333333;
|
|
|
color: #ffffff;
|
|
|
border: 1px solid #555555;
|
|
|
selection-background-color: #1db954;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
refresh_info_layout.addWidget(refresh_label)
|
|
|
refresh_info_layout.addWidget(self.refresh_interval_combo)
|
|
|
|
|
|
# Current artist display
|
|
|
artist_info_layout = QVBoxLayout()
|
|
|
artist_info_layout.setSpacing(4)
|
|
|
|
|
|
current_label = QLabel("Current Artist:")
|
|
|
current_label.setFont(QFont("Arial", 9))
|
|
|
current_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
|
|
self.current_artist_label = QLabel("Not running")
|
|
|
self.current_artist_label.setFont(QFont("Arial", 11, QFont.Weight.Medium))
|
|
|
self.current_artist_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
artist_info_layout.addWidget(current_label)
|
|
|
artist_info_layout.addWidget(self.current_artist_label)
|
|
|
|
|
|
settings_layout.addLayout(refresh_info_layout)
|
|
|
settings_layout.addLayout(artist_info_layout)
|
|
|
settings_layout.addStretch()
|
|
|
|
|
|
control_layout.addLayout(button_layout)
|
|
|
control_layout.addLayout(settings_layout)
|
|
|
|
|
|
# Progress section
|
|
|
progress_layout = QVBoxLayout()
|
|
|
progress_layout.setSpacing(8)
|
|
|
|
|
|
progress_info_layout = QHBoxLayout()
|
|
|
|
|
|
self.progress_label = QLabel("Progress: 0%")
|
|
|
self.progress_label.setFont(QFont("Arial", 10))
|
|
|
self.progress_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
self.count_label = QLabel("0 / 0 artists")
|
|
|
self.count_label.setFont(QFont("Arial", 9))
|
|
|
self.count_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
|
|
progress_info_layout.addWidget(self.progress_label)
|
|
|
progress_info_layout.addStretch()
|
|
|
progress_info_layout.addWidget(self.count_label)
|
|
|
|
|
|
self.progress_bar = QProgressBar()
|
|
|
self.progress_bar.setFixedHeight(8)
|
|
|
self.progress_bar.setRange(0, 100)
|
|
|
self.progress_bar.setValue(0)
|
|
|
self.progress_bar.setStyleSheet("""
|
|
|
QProgressBar {
|
|
|
border: none;
|
|
|
border-radius: 4px;
|
|
|
background: #555555;
|
|
|
}
|
|
|
QProgressBar::chunk {
|
|
|
background: #1db954;
|
|
|
border-radius: 4px;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
progress_layout.addLayout(progress_info_layout)
|
|
|
progress_layout.addWidget(self.progress_bar)
|
|
|
|
|
|
layout.addWidget(header_label)
|
|
|
layout.addWidget(info_label)
|
|
|
layout.addLayout(control_layout)
|
|
|
layout.addLayout(progress_layout)
|
|
|
|
|
|
def update_progress(self, is_running: bool, current_artist: str, processed: int, total: int, percentage: float):
|
|
|
if is_running:
|
|
|
self.start_button.setText("Stop Update")
|
|
|
self.start_button.setEnabled(True)
|
|
|
self.current_artist_label.setText(current_artist if current_artist else "Initializing...")
|
|
|
self.progress_label.setText(f"Progress: {percentage:.1f}%")
|
|
|
self.count_label.setText(f"{processed} / {total} artists")
|
|
|
self.progress_bar.setValue(int(percentage))
|
|
|
else:
|
|
|
self.start_button.setText("Begin Metadata Update")
|
|
|
self.start_button.setEnabled(True)
|
|
|
self.current_artist_label.setText("Not running")
|
|
|
self.progress_label.setText("Progress: 0%")
|
|
|
self.count_label.setText("0 / 0 artists")
|
|
|
self.progress_bar.setValue(0)
|
|
|
|
|
|
def get_refresh_interval_days(self) -> int:
|
|
|
"""Convert dropdown selection to number of days"""
|
|
|
interval_map = {
|
|
|
"6 months": 180,
|
|
|
"3 months": 90,
|
|
|
"1 month": 30,
|
|
|
"2 weeks": 14,
|
|
|
"1 week": 7,
|
|
|
"Full refresh": 0 # 0 means update everything
|
|
|
}
|
|
|
|
|
|
selected = self.refresh_interval_combo.currentText()
|
|
|
return interval_map.get(selected, 30) # Default to 1 month
|
|
|
|
|
|
class ActivityItem(QWidget):
|
|
|
def __init__(self, icon: str, title: str, subtitle: str, time: str, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self.setup_ui(icon, title, subtitle, time)
|
|
|
|
|
|
def setup_ui(self, icon: str, title: str, subtitle: str, time: str):
|
|
|
self.setFixedHeight(60)
|
|
|
|
|
|
layout = QHBoxLayout(self)
|
|
|
layout.setContentsMargins(15, 10, 15, 10)
|
|
|
layout.setSpacing(15)
|
|
|
|
|
|
# Icon
|
|
|
icon_label = QLabel(icon)
|
|
|
icon_label.setFixedSize(32, 32)
|
|
|
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
icon_label.setStyleSheet("""
|
|
|
QLabel {
|
|
|
color: #1db954;
|
|
|
font-size: 18px;
|
|
|
background: rgba(29, 185, 84, 0.1);
|
|
|
border-radius: 16px;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# Text content
|
|
|
text_layout = QVBoxLayout()
|
|
|
text_layout.setSpacing(2)
|
|
|
|
|
|
self.title_label = QLabel(title)
|
|
|
self.title_label.setFont(QFont("Arial", 10, QFont.Weight.Medium))
|
|
|
self.title_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
self.subtitle_label = QLabel(subtitle)
|
|
|
self.subtitle_label.setFont(QFont("Arial", 9))
|
|
|
self.subtitle_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
|
|
text_layout.addWidget(self.title_label)
|
|
|
text_layout.addWidget(self.subtitle_label)
|
|
|
|
|
|
# Time
|
|
|
time_label = QLabel(time)
|
|
|
time_label.setFont(QFont("Arial", 9))
|
|
|
time_label.setStyleSheet("color: #b3b3b3;")
|
|
|
time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
|
|
|
|
|
|
layout.addWidget(icon_label)
|
|
|
layout.addLayout(text_layout)
|
|
|
layout.addStretch()
|
|
|
layout.addWidget(time_label)
|
|
|
|
|
|
class DashboardPage(QWidget):
|
|
|
database_updated_externally = pyqtSignal()
|
|
|
|
|
|
# Watchlist scanning signals for live updates to open modal
|
|
|
watchlist_scan_started = pyqtSignal()
|
|
|
watchlist_artist_scan_started = pyqtSignal(str) # artist_name
|
|
|
watchlist_artist_scan_completed = pyqtSignal(str, int, int, bool) # artist_name, albums_checked, new_tracks, success
|
|
|
watchlist_scan_completed = pyqtSignal(int, int, int) # total_artists, total_new_tracks, total_added_to_wishlist
|
|
|
|
|
|
def __init__(self, parent=None):
|
|
|
super().__init__(parent)
|
|
|
|
|
|
# Initialize data provider
|
|
|
self.data_provider = DashboardDataProvider()
|
|
|
self.data_provider.service_status_updated.connect(self.on_service_status_updated)
|
|
|
self.data_provider.download_stats_updated.connect(self.on_download_stats_updated)
|
|
|
self.data_provider.metadata_progress_updated.connect(self.on_metadata_progress_updated)
|
|
|
self.data_provider.sync_progress_updated.connect(self.on_sync_progress_updated)
|
|
|
self.data_provider.system_stats_updated.connect(self.on_system_stats_updated)
|
|
|
self.data_provider.activity_item_added.connect(self.add_activity_item)
|
|
|
|
|
|
# Service status cards
|
|
|
self.service_cards = {}
|
|
|
|
|
|
# Track previous service status to only show changes in activity
|
|
|
self.previous_service_status = {}
|
|
|
|
|
|
# Track if placeholder exists
|
|
|
self.has_placeholder = True
|
|
|
|
|
|
# Stats cards
|
|
|
self.stats_cards = {}
|
|
|
|
|
|
self.setup_ui()
|
|
|
self.database_updated_externally.connect(self.refresh_database_statistics)
|
|
|
self.database_updated_externally.connect(self.update_watchlist_button_count)
|
|
|
|
|
|
# Initialize list to track active stats workers
|
|
|
self._active_stats_workers = []
|
|
|
|
|
|
# Initialize wishlist service and timers
|
|
|
self.wishlist_service = get_wishlist_service()
|
|
|
|
|
|
# Timer for updating wishlist button count
|
|
|
self.wishlist_update_timer = QTimer()
|
|
|
self.wishlist_update_timer.timeout.connect(self.update_wishlist_button_count)
|
|
|
self.wishlist_update_timer.timeout.connect(self.update_watchlist_button_count)
|
|
|
self.wishlist_update_timer.start(30000) # Update every 30 seconds
|
|
|
|
|
|
# Timer for automatic wishlist retry processing
|
|
|
self.wishlist_retry_timer = QTimer()
|
|
|
self.wishlist_retry_timer.setSingleShot(True) # Single shot timer, we'll restart it after each completion
|
|
|
self.wishlist_retry_timer.timeout.connect(self.process_wishlist_automatically)
|
|
|
self.wishlist_retry_timer.start(60000) # Start first processing 1 minute after app launch (60000 ms)
|
|
|
|
|
|
# Track if automatic processing is currently running
|
|
|
self.auto_processing_wishlist = False
|
|
|
self.wishlist_download_modal = None
|
|
|
|
|
|
# Watchlist scanning timer and state
|
|
|
self.watchlist_scan_timer = QTimer()
|
|
|
self.watchlist_scan_timer.setSingleShot(True)
|
|
|
self.watchlist_scan_timer.timeout.connect(self.process_watchlist_automatically)
|
|
|
self.watchlist_scan_timer.start(60000) # Start first scan 1 minute after app launch
|
|
|
|
|
|
self.auto_processing_watchlist = False
|
|
|
self.watchlist_status_modal = None
|
|
|
self.background_watchlist_worker = None
|
|
|
# Load initial database statistics (with delay to avoid startup issues)
|
|
|
QTimer.singleShot(1000, self.refresh_database_statistics)
|
|
|
# Load initial wishlist count (with slight delay)
|
|
|
QTimer.singleShot(1500, self.update_wishlist_button_count)
|
|
|
QTimer.singleShot(1500, self.update_watchlist_button_count)
|
|
|
|
|
|
|
|
|
def _ensure_wishlist_modal_exists(self):
|
|
|
"""Creates the persistent wishlist modal instance if it doesn't exist."""
|
|
|
if self.wishlist_download_modal is None:
|
|
|
logger.info("Creating persistent wishlist download modal instance.")
|
|
|
spotify_client = self.service_clients.get('spotify_client')
|
|
|
plex_client = self.service_clients.get('plex_client')
|
|
|
soulseek_client = self.service_clients.get('soulseek_client')
|
|
|
downloads_page = self.downloads_page
|
|
|
|
|
|
if not all([spotify_client, plex_client, soulseek_client, downloads_page]):
|
|
|
QMessageBox.critical(self, "Error", "Required services not available for wishlist search.")
|
|
|
return False
|
|
|
|
|
|
self.wishlist_download_modal = DownloadMissingWishlistTracksModal(
|
|
|
self.wishlist_service, self, downloads_page,
|
|
|
spotify_client, plex_client, soulseek_client
|
|
|
)
|
|
|
self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished)
|
|
|
return True
|
|
|
|
|
|
def set_service_clients(self, spotify_client, plex_client, soulseek_client, downloads_page=None):
|
|
|
"""Called from main window to provide service client references"""
|
|
|
self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client)
|
|
|
|
|
|
# Store service clients for wishlist modal
|
|
|
self.service_clients = {
|
|
|
'spotify_client': spotify_client,
|
|
|
'plex_client': plex_client,
|
|
|
'soulseek_client': soulseek_client,
|
|
|
'downloads_page': downloads_page
|
|
|
}
|
|
|
|
|
|
# Initialize Plex scan manager for wishlist modal integration
|
|
|
self.scan_manager = None
|
|
|
if plex_client:
|
|
|
self.scan_manager = PlexScanManager(plex_client, delay_seconds=60)
|
|
|
# Add automatic incremental database update after Plex scan completion
|
|
|
self.scan_manager.add_scan_completion_callback(self._on_plex_scan_completed)
|
|
|
logger.info("✅ PlexScanManager initialized for Dashboard wishlist modal")
|
|
|
|
|
|
def set_page_references(self, downloads_page, sync_page):
|
|
|
"""Called from main window to provide page references for live data"""
|
|
|
self.downloads_page = downloads_page
|
|
|
self.sync_page = sync_page
|
|
|
self.data_provider.set_page_references(downloads_page, sync_page)
|
|
|
|
|
|
def set_app_start_time(self, start_time):
|
|
|
"""Called from main window to provide app start time for uptime calculation"""
|
|
|
self.data_provider.set_app_start_time(start_time)
|
|
|
|
|
|
def set_toast_manager(self, toast_manager):
|
|
|
"""Set the toast manager for showing notifications"""
|
|
|
self.toast_manager = toast_manager
|
|
|
|
|
|
def _on_plex_scan_completed(self):
|
|
|
"""Callback triggered when Plex scan completes - start automatic incremental database update"""
|
|
|
try:
|
|
|
# Import here to avoid circular imports
|
|
|
from database import get_database
|
|
|
from core.database_update_worker import DatabaseUpdateWorker
|
|
|
|
|
|
# Check if we should run incremental update
|
|
|
plex_client = self.service_clients.get('plex_client')
|
|
|
if not plex_client or not plex_client.is_connected():
|
|
|
logger.debug("Plex not connected - skipping automatic database update")
|
|
|
return
|
|
|
|
|
|
# Check if database has a previous full refresh
|
|
|
database = get_database()
|
|
|
last_full_refresh = database.get_last_full_refresh()
|
|
|
if not last_full_refresh:
|
|
|
logger.info("No previous full refresh found - skipping automatic incremental update")
|
|
|
return
|
|
|
|
|
|
# Check if database has sufficient content
|
|
|
try:
|
|
|
stats = database.get_database_info()
|
|
|
track_count = stats.get('tracks', 0)
|
|
|
|
|
|
if track_count < 100:
|
|
|
logger.info(f"Database has only {track_count} tracks - skipping automatic incremental update")
|
|
|
return
|
|
|
except Exception as e:
|
|
|
logger.warning(f"Could not check database stats - skipping automatic update: {e}")
|
|
|
return
|
|
|
|
|
|
# All conditions met - start incremental update
|
|
|
logger.info("🎵 Starting automatic incremental database update after Plex scan")
|
|
|
self._start_automatic_incremental_update()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error in Plex scan completion callback: {e}")
|
|
|
|
|
|
def _start_automatic_incremental_update(self):
|
|
|
"""Start the automatic incremental database update"""
|
|
|
try:
|
|
|
from core.database_update_worker import DatabaseUpdateWorker
|
|
|
|
|
|
# Avoid duplicate workers
|
|
|
if hasattr(self, '_auto_database_worker') and self._auto_database_worker and self._auto_database_worker.isRunning():
|
|
|
logger.debug("Automatic database update already running")
|
|
|
return
|
|
|
|
|
|
# Create worker for incremental update only
|
|
|
plex_client = self.service_clients.get('plex_client')
|
|
|
self._auto_database_worker = DatabaseUpdateWorker(
|
|
|
plex_client,
|
|
|
"database/music_library.db",
|
|
|
full_refresh=False # Always incremental for automatic updates
|
|
|
)
|
|
|
|
|
|
# Connect completion signal to log result
|
|
|
self._auto_database_worker.finished.connect(self._on_auto_update_finished)
|
|
|
self._auto_database_worker.error.connect(self._on_auto_update_error)
|
|
|
|
|
|
# Start the update
|
|
|
self._auto_database_worker.start()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error starting automatic incremental update: {e}")
|
|
|
|
|
|
def _on_auto_update_finished(self, total_artists, total_albums, total_tracks, successful, failed):
|
|
|
"""Handle completion of automatic database update"""
|
|
|
try:
|
|
|
if successful > 0:
|
|
|
logger.info(f"✅ Automatic database update completed: {successful} items processed successfully")
|
|
|
else:
|
|
|
logger.info("💡 Automatic database update completed - no new content found")
|
|
|
self.refresh_database_statistics()
|
|
|
# Clean up the worker
|
|
|
if hasattr(self, '_auto_database_worker'):
|
|
|
self._auto_database_worker.deleteLater()
|
|
|
delattr(self, '_auto_database_worker')
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error handling automatic update completion: {e}")
|
|
|
|
|
|
def _on_auto_update_error(self, error_message):
|
|
|
"""Handle error in automatic database update"""
|
|
|
logger.warning(f"Automatic database update encountered an error: {error_message}")
|
|
|
|
|
|
# Clean up the worker
|
|
|
if hasattr(self, '_auto_database_worker'):
|
|
|
self._auto_database_worker.deleteLater()
|
|
|
delattr(self, '_auto_database_worker')
|
|
|
|
|
|
def setup_ui(self):
|
|
|
self.setStyleSheet("""
|
|
|
DashboardPage {
|
|
|
background: #191414;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# Main scroll area
|
|
|
scroll_area = QScrollArea()
|
|
|
scroll_area.setWidgetResizable(True)
|
|
|
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
|
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
|
scroll_area.setStyleSheet("""
|
|
|
QScrollArea {
|
|
|
border: none;
|
|
|
background: #191414;
|
|
|
}
|
|
|
QScrollBar:vertical {
|
|
|
background: #333333;
|
|
|
width: 12px;
|
|
|
border-radius: 6px;
|
|
|
}
|
|
|
QScrollBar::handle:vertical {
|
|
|
background: #555555;
|
|
|
border-radius: 6px;
|
|
|
min-height: 20px;
|
|
|
}
|
|
|
QScrollBar::handle:vertical:hover {
|
|
|
background: #666666;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# Scroll content widget
|
|
|
scroll_content = QWidget()
|
|
|
scroll_area.setWidget(scroll_content)
|
|
|
|
|
|
main_layout = QVBoxLayout(scroll_content)
|
|
|
main_layout.setContentsMargins(30, 30, 30, 30)
|
|
|
main_layout.setSpacing(25)
|
|
|
|
|
|
# Header
|
|
|
header = self.create_header()
|
|
|
main_layout.addWidget(header)
|
|
|
|
|
|
# Service Status Section
|
|
|
service_section = self.create_service_status_section()
|
|
|
main_layout.addWidget(service_section)
|
|
|
|
|
|
# System Stats Section
|
|
|
stats_section = self.create_stats_section()
|
|
|
main_layout.addWidget(stats_section)
|
|
|
|
|
|
# Plex Metadata Updater
|
|
|
metadata_section = self.create_metadata_section()
|
|
|
main_layout.addWidget(metadata_section)
|
|
|
|
|
|
# Recent Activity
|
|
|
activity_section = self.create_activity_section()
|
|
|
main_layout.addWidget(activity_section)
|
|
|
|
|
|
main_layout.addStretch()
|
|
|
|
|
|
# Set main layout
|
|
|
page_layout = QVBoxLayout(self)
|
|
|
page_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
page_layout.addWidget(scroll_area)
|
|
|
|
|
|
def create_header(self):
|
|
|
header = QWidget()
|
|
|
main_layout = QHBoxLayout(header)
|
|
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
main_layout.setSpacing(20)
|
|
|
|
|
|
# Left side - Title and subtitle
|
|
|
left_widget = QWidget()
|
|
|
left_layout = QVBoxLayout(left_widget)
|
|
|
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
left_layout.setSpacing(5)
|
|
|
|
|
|
# Welcome message
|
|
|
welcome_label = QLabel("System Dashboard")
|
|
|
welcome_label.setFont(QFont("Arial", 28, QFont.Weight.Bold))
|
|
|
welcome_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
# Subtitle
|
|
|
subtitle_label = QLabel("Monitor your music system health and manage operations")
|
|
|
subtitle_label.setFont(QFont("Arial", 14))
|
|
|
subtitle_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
|
|
left_layout.addWidget(welcome_label)
|
|
|
left_layout.addWidget(subtitle_label)
|
|
|
|
|
|
# Right side - Wishlist button
|
|
|
right_widget = QWidget()
|
|
|
right_layout = QVBoxLayout(right_widget)
|
|
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
right_layout.setSpacing(0)
|
|
|
|
|
|
# Spacer to align button with title
|
|
|
right_layout.addStretch()
|
|
|
|
|
|
# Buttons layout
|
|
|
buttons_layout = QHBoxLayout()
|
|
|
buttons_layout.setSpacing(10)
|
|
|
|
|
|
# Wishlist button
|
|
|
self.wishlist_button = QPushButton("🎵 Wishlist (0)")
|
|
|
self.wishlist_button.setFixedHeight(45)
|
|
|
self.wishlist_button.setFixedWidth(150)
|
|
|
self.wishlist_button.clicked.connect(self.on_wishlist_button_clicked)
|
|
|
self.wishlist_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #1db954;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #000000;
|
|
|
font-size: 12px;
|
|
|
font-weight: bold;
|
|
|
padding: 8px 16px;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #1ed760;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #169c46;
|
|
|
}
|
|
|
QPushButton:disabled {
|
|
|
background: #404040;
|
|
|
color: #666666;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
# Watchlist button
|
|
|
self.watchlist_button = QPushButton("👁️ Watchlist (0)")
|
|
|
self.watchlist_button.setFixedHeight(45)
|
|
|
self.watchlist_button.setFixedWidth(150)
|
|
|
self.watchlist_button.clicked.connect(self.on_watchlist_button_clicked)
|
|
|
self.watchlist_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #ffc107;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #000000;
|
|
|
font-size: 12px;
|
|
|
font-weight: bold;
|
|
|
padding: 8px 16px;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #ffca28;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #ff8f00;
|
|
|
}
|
|
|
QPushButton:disabled {
|
|
|
background: #404040;
|
|
|
color: #666666;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
buttons_layout.addWidget(self.watchlist_button)
|
|
|
buttons_layout.addWidget(self.wishlist_button)
|
|
|
|
|
|
right_layout.addLayout(buttons_layout)
|
|
|
right_layout.addStretch()
|
|
|
|
|
|
# Add to main layout
|
|
|
main_layout.addWidget(left_widget)
|
|
|
main_layout.addStretch() # Push button to the right
|
|
|
main_layout.addWidget(right_widget)
|
|
|
|
|
|
return header
|
|
|
|
|
|
def create_service_status_section(self):
|
|
|
section = QWidget()
|
|
|
layout = QVBoxLayout(section)
|
|
|
layout.setSpacing(15)
|
|
|
|
|
|
# Section header
|
|
|
header_label = QLabel("Service Status")
|
|
|
header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
|
header_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
# Service cards grid
|
|
|
cards_layout = QHBoxLayout()
|
|
|
cards_layout.setSpacing(20)
|
|
|
|
|
|
# Create service status cards
|
|
|
services = ['Spotify', 'Plex', 'Soulseek']
|
|
|
for service in services:
|
|
|
card = ServiceStatusCard(service)
|
|
|
card.test_button.clicked.connect(lambda checked, s=service.lower(): self.test_service_connection(s))
|
|
|
self.service_cards[service.lower()] = card
|
|
|
cards_layout.addWidget(card)
|
|
|
|
|
|
cards_layout.addStretch()
|
|
|
|
|
|
layout.addWidget(header_label)
|
|
|
layout.addLayout(cards_layout)
|
|
|
|
|
|
return section
|
|
|
|
|
|
def create_stats_section(self):
|
|
|
section = QWidget()
|
|
|
layout = QVBoxLayout(section)
|
|
|
layout.setSpacing(15)
|
|
|
|
|
|
# Section header
|
|
|
header_label = QLabel("System Statistics")
|
|
|
header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
|
header_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
# Stats grid
|
|
|
stats_grid = QGridLayout()
|
|
|
stats_grid.setSpacing(20)
|
|
|
|
|
|
# Create stats cards
|
|
|
stats_data = [
|
|
|
("Active Downloads", "0", "Currently downloading", "active_downloads"),
|
|
|
("Finished Downloads", "0", "Completed today", "finished_downloads"),
|
|
|
("Download Speed", "0 KB/s", "Combined speed", "download_speed"),
|
|
|
("Active Syncs", "0", "Playlists syncing", "active_syncs"),
|
|
|
("System Uptime", "0m", "Application runtime", "uptime"),
|
|
|
("Memory Usage", "--", "Current usage", "memory")
|
|
|
]
|
|
|
|
|
|
for i, (title, value, subtitle, key) in enumerate(stats_data):
|
|
|
card = StatCard(title, value, subtitle, clickable=False)
|
|
|
self.stats_cards[key] = card
|
|
|
stats_grid.addWidget(card, i // 3, i % 3)
|
|
|
|
|
|
layout.addWidget(header_label)
|
|
|
layout.addLayout(stats_grid)
|
|
|
|
|
|
return section
|
|
|
|
|
|
def create_metadata_section(self):
|
|
|
section = QWidget()
|
|
|
layout = QVBoxLayout(section)
|
|
|
layout.setSpacing(15)
|
|
|
|
|
|
# Section header
|
|
|
header_label = QLabel("Tools & Operations")
|
|
|
header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
|
header_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
# Database updater widget (FIRST)
|
|
|
self.database_widget = DatabaseUpdaterWidget()
|
|
|
self.database_widget.start_button.clicked.connect(self.toggle_database_update)
|
|
|
|
|
|
# Metadata updater widget (SECOND)
|
|
|
self.metadata_widget = MetadataUpdaterWidget()
|
|
|
self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update)
|
|
|
|
|
|
layout.addWidget(header_label)
|
|
|
layout.addWidget(self.database_widget)
|
|
|
layout.addWidget(self.metadata_widget)
|
|
|
|
|
|
return section
|
|
|
|
|
|
def create_activity_section(self):
|
|
|
activity_widget = QWidget()
|
|
|
layout = QVBoxLayout(activity_widget)
|
|
|
layout.setSpacing(15)
|
|
|
|
|
|
# Section header
|
|
|
header_label = QLabel("Recent Activity")
|
|
|
header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
|
header_label.setStyleSheet("color: #ffffff;")
|
|
|
|
|
|
# Activity container
|
|
|
activity_container = QFrame()
|
|
|
activity_container.setStyleSheet("""
|
|
|
QFrame {
|
|
|
background: #282828;
|
|
|
border-radius: 8px;
|
|
|
border: 1px solid #404040;
|
|
|
}
|
|
|
""")
|
|
|
|
|
|
activity_layout = QVBoxLayout(activity_container)
|
|
|
activity_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
activity_layout.setSpacing(1)
|
|
|
|
|
|
# Activity feed will be populated dynamically
|
|
|
self.activity_layout = activity_layout
|
|
|
|
|
|
# Add initial placeholder
|
|
|
placeholder_item = ActivityItem("📊", "System Started", "Dashboard initialized successfully", "Now")
|
|
|
activity_layout.addWidget(placeholder_item)
|
|
|
|
|
|
layout.addWidget(header_label)
|
|
|
layout.addWidget(activity_container)
|
|
|
|
|
|
return activity_widget
|
|
|
|
|
|
def test_service_connection(self, service: str):
|
|
|
"""Test connection to a specific service"""
|
|
|
if service in self.service_cards:
|
|
|
card = self.service_cards[service]
|
|
|
|
|
|
# Prevent multiple simultaneous tests
|
|
|
if hasattr(self.data_provider, '_test_threads') and service in self.data_provider._test_threads:
|
|
|
if self.data_provider._test_threads[service].isRunning():
|
|
|
return
|
|
|
|
|
|
card.test_button.setText("Testing...")
|
|
|
card.test_button.setEnabled(False)
|
|
|
|
|
|
# Update status to testing state
|
|
|
card.status_indicator.setStyleSheet("color: #ffaa00;") # Orange
|
|
|
card.status_text.setText("Testing connection...")
|
|
|
|
|
|
# Add activity item for test initiation
|
|
|
self.add_activity_item("🔍", f"Testing {service.capitalize()}", "Connection test initiated", "Now")
|
|
|
|
|
|
# Start test
|
|
|
self.data_provider.test_service_connection(service)
|
|
|
|
|
|
def toggle_database_update(self):
|
|
|
"""Toggle database update process"""
|
|
|
current_text = self.database_widget.start_button.text()
|
|
|
if "Update Database" in current_text:
|
|
|
# Start database update
|
|
|
self.start_database_update()
|
|
|
else:
|
|
|
# Stop database update
|
|
|
self.stop_database_update()
|
|
|
|
|
|
def start_database_update(self):
|
|
|
"""Start the SoulSync database update process"""
|
|
|
logger.debug(f"Starting database update - data_provider exists: {hasattr(self, 'data_provider')}")
|
|
|
if hasattr(self, 'data_provider') and hasattr(self.data_provider, 'service_clients'):
|
|
|
logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}")
|
|
|
logger.debug(f"Plex client: {self.data_provider.service_clients.get('plex')}")
|
|
|
|
|
|
if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'):
|
|
|
self.add_activity_item("❌", "Database Update", "Plex client not available", "Now")
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
# Get update type from dropdown
|
|
|
full_refresh = self.database_widget.is_full_refresh()
|
|
|
|
|
|
# Show confirmation dialog for full refresh
|
|
|
if full_refresh:
|
|
|
reply = QMessageBox.question(
|
|
|
self,
|
|
|
"Confirm Full Database Refresh",
|
|
|
"⚠️ You've selected FULL REFRESH mode.\n\n"
|
|
|
"This will completely rebuild your database and may take several minutes.\n"
|
|
|
"All existing data will be cleared and rebuilt from your Plex library.\n\n"
|
|
|
"Are you sure you want to continue?",
|
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
|
QMessageBox.StandardButton.No # Default to No for safety
|
|
|
)
|
|
|
|
|
|
if reply != QMessageBox.StandardButton.Yes:
|
|
|
logger.debug("Full refresh cancelled by user")
|
|
|
return # Cancel the operation
|
|
|
|
|
|
# Start the database update worker
|
|
|
self.database_worker = DatabaseUpdateWorker(
|
|
|
self.data_provider.service_clients['plex_client'],
|
|
|
"database/music_library.db",
|
|
|
full_refresh
|
|
|
)
|
|
|
|
|
|
# Connect signals
|
|
|
self.database_worker.progress_updated.connect(self.on_database_progress)
|
|
|
self.database_worker.artist_processed.connect(self.on_database_artist_processed)
|
|
|
self.database_worker.finished.connect(self.on_database_finished)
|
|
|
self.database_worker.error.connect(self.on_database_error)
|
|
|
self.database_worker.phase_changed.connect(self.on_database_phase_changed)
|
|
|
|
|
|
# Update UI and start
|
|
|
self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0)
|
|
|
update_type = "Full refresh" if full_refresh else "Incremental update"
|
|
|
self.add_activity_item("🗄️", "Database Update", f"Starting {update_type.lower()}...", "Now")
|
|
|
|
|
|
self.database_worker.start()
|
|
|
|
|
|
# Start a timer to refresh database statistics during update
|
|
|
self.start_database_stats_refresh()
|
|
|
|
|
|
except Exception as e:
|
|
|
self.add_activity_item("❌", "Database Update", f"Failed to start: {str(e)}", "Now")
|
|
|
|
|
|
def stop_database_update(self):
|
|
|
"""Stop the database update process"""
|
|
|
if hasattr(self, 'database_worker') and self.database_worker.isRunning():
|
|
|
self.database_worker.stop()
|
|
|
self.database_worker.wait(3000) # Wait up to 3 seconds
|
|
|
if self.database_worker.isRunning():
|
|
|
self.database_worker.terminate()
|
|
|
|
|
|
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
|
|
self.add_activity_item("⏹️", "Database Update", "Stopped database update process", "Now")
|
|
|
|
|
|
# Stop statistics refresh timer
|
|
|
self.stop_database_stats_refresh()
|
|
|
|
|
|
def on_database_progress(self, current_item: str, processed: int, total: int, percentage: float):
|
|
|
"""Handle database update progress"""
|
|
|
self.database_widget.update_progress(True, current_item, processed, total, percentage)
|
|
|
|
|
|
def on_database_artist_processed(self, artist_name: str, success: bool, details: str, album_count: int, track_count: int):
|
|
|
"""Handle individual artist processing completion"""
|
|
|
if success:
|
|
|
self.add_activity_item("✅", "Artist Processed", f"'{artist_name}' - {details}", "Now")
|
|
|
else:
|
|
|
self.add_activity_item("❌", "Artist Failed", f"'{artist_name}' - {details}", "Now")
|
|
|
|
|
|
def on_database_finished(self, total_artists: int, total_albums: int, total_tracks: int, successful: int, failed: int):
|
|
|
"""Handle database update completion"""
|
|
|
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
|
|
summary = f"Processed {total_artists} artists, {total_albums} albums, {total_tracks} tracks"
|
|
|
self.add_activity_item("🗄️", "Database Complete", summary, "Now")
|
|
|
|
|
|
# Stop statistics refresh timer and do final update
|
|
|
self.stop_database_stats_refresh()
|
|
|
self.refresh_database_statistics()
|
|
|
|
|
|
def on_database_error(self, error_message: str):
|
|
|
"""Handle database update error"""
|
|
|
self.database_widget.update_progress(False, "", 0, 0, 0.0)
|
|
|
self.add_activity_item("❌", "Database Error", error_message, "Now")
|
|
|
|
|
|
# Stop statistics refresh timer
|
|
|
self.stop_database_stats_refresh()
|
|
|
|
|
|
def on_database_phase_changed(self, phase: str):
|
|
|
"""Handle database update phase changes"""
|
|
|
self.database_widget.update_phase(phase)
|
|
|
|
|
|
def start_database_stats_refresh(self):
|
|
|
"""Start periodic database statistics refresh during update"""
|
|
|
# Create timer to refresh stats every 5 seconds during update
|
|
|
if not hasattr(self, 'database_stats_timer'):
|
|
|
self.database_stats_timer = QTimer()
|
|
|
self.database_stats_timer.timeout.connect(self.refresh_database_statistics)
|
|
|
|
|
|
self.database_stats_timer.start(5000) # Every 5 seconds
|
|
|
|
|
|
def stop_database_stats_refresh(self):
|
|
|
"""Stop periodic database statistics refresh"""
|
|
|
if hasattr(self, 'database_stats_timer'):
|
|
|
self.database_stats_timer.stop()
|
|
|
|
|
|
def refresh_database_statistics(self):
|
|
|
"""Refresh database statistics display"""
|
|
|
try:
|
|
|
# Check if database widget exists first
|
|
|
if not hasattr(self, 'database_widget') or self.database_widget is None:
|
|
|
return
|
|
|
|
|
|
# Get statistics in background thread to avoid blocking UI
|
|
|
stats_worker = DatabaseStatsWorker("database/music_library.db")
|
|
|
|
|
|
# Track the worker for cleanup
|
|
|
if not hasattr(self, '_active_stats_workers'):
|
|
|
self._active_stats_workers = []
|
|
|
self._active_stats_workers.append(stats_worker)
|
|
|
|
|
|
# Connect signals
|
|
|
stats_worker.stats_updated.connect(self.update_database_info)
|
|
|
stats_worker.finished.connect(lambda: self._cleanup_stats_worker(stats_worker))
|
|
|
|
|
|
stats_worker.start()
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error refreshing database statistics: {e}")
|
|
|
# Fallback to default stats to prevent crashes
|
|
|
if hasattr(self, 'database_widget') and self.database_widget:
|
|
|
fallback_info = {
|
|
|
'artists': 0,
|
|
|
'albums': 0,
|
|
|
'tracks': 0,
|
|
|
'database_size_mb': 0.0,
|
|
|
'last_full_refresh': None
|
|
|
}
|
|
|
self.update_database_info(fallback_info)
|
|
|
|
|
|
def update_database_info(self, info: dict):
|
|
|
"""Update database statistics and last refresh info"""
|
|
|
try:
|
|
|
# Update basic statistics
|
|
|
self.database_widget.update_statistics(info)
|
|
|
|
|
|
# Update last refresh information
|
|
|
last_refresh_date = info.get('last_full_refresh')
|
|
|
self.database_widget.update_last_refresh_info(last_refresh_date)
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error updating database info: {e}")
|
|
|
|
|
|
def on_wishlist_modal_finished(self):
|
|
|
"""Called when the modal's download process is completely done or cancelled."""
|
|
|
logger.info("Wishlist download process finished. Resetting modal instance.")
|
|
|
# We can now safely discard the modal instance. A new one will be created on the next run.
|
|
|
self.wishlist_download_modal = None
|
|
|
self.update_wishlist_button_count()
|
|
|
|
|
|
def start_wishlist_search_process(self):
|
|
|
"""
|
|
|
Ensures the wishlist modal exists and tells it to start the search process.
|
|
|
This is the single entry point for automatic searches.
|
|
|
"""
|
|
|
if not self._ensure_wishlist_modal_exists():
|
|
|
return # Modal creation failed
|
|
|
|
|
|
# Tell the modal to begin its search process
|
|
|
self.wishlist_download_modal.start_search()
|
|
|
|
|
|
|
|
|
def _cleanup_stats_worker(self, worker):
|
|
|
"""Clean up a finished stats worker"""
|
|
|
try:
|
|
|
if hasattr(self, '_active_stats_workers') and worker in self._active_stats_workers:
|
|
|
self._active_stats_workers.remove(worker)
|
|
|
worker.deleteLater()
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error cleaning up stats worker: {e}")
|
|
|
|
|
|
def toggle_metadata_update(self):
|
|
|
"""Toggle metadata update process"""
|
|
|
current_text = self.metadata_widget.start_button.text()
|
|
|
if "Begin" in current_text:
|
|
|
# Start metadata update
|
|
|
self.start_metadata_update()
|
|
|
else:
|
|
|
# Stop metadata update
|
|
|
self.stop_metadata_update()
|
|
|
|
|
|
def start_metadata_update(self):
|
|
|
"""Start the Plex metadata update process"""
|
|
|
logger.debug(f"Starting metadata update - data_provider exists: {hasattr(self, 'data_provider')}")
|
|
|
if hasattr(self, 'data_provider') and hasattr(self.data_provider, 'service_clients'):
|
|
|
logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}")
|
|
|
|
|
|
if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'):
|
|
|
self.add_activity_item("❌", "Metadata Update", "Plex client not available", "Now")
|
|
|
return
|
|
|
|
|
|
if not self.data_provider.service_clients.get('spotify_client'):
|
|
|
self.add_activity_item("❌", "Metadata Update", "Spotify client not available", "Now")
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
# Get refresh interval from dropdown
|
|
|
refresh_interval_days = self.metadata_widget.get_refresh_interval_days()
|
|
|
|
|
|
# Start the metadata update worker (it will handle artist retrieval in background)
|
|
|
self.metadata_worker = MetadataUpdateWorker(
|
|
|
None, # Artists will be loaded in the worker thread
|
|
|
self.data_provider.service_clients['plex_client'],
|
|
|
self.data_provider.service_clients['spotify_client'],
|
|
|
refresh_interval_days
|
|
|
)
|
|
|
|
|
|
# Connect signals
|
|
|
self.metadata_worker.progress_updated.connect(self.on_metadata_progress)
|
|
|
self.metadata_worker.artist_updated.connect(self.on_artist_updated)
|
|
|
self.metadata_worker.finished.connect(self.on_metadata_finished)
|
|
|
self.metadata_worker.error.connect(self.on_metadata_error)
|
|
|
self.metadata_worker.artists_loaded.connect(self.on_artists_loaded)
|
|
|
|
|
|
# Update UI and start
|
|
|
self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0)
|
|
|
self.add_activity_item("🎵", "Metadata Update", "Loading artists from Plex library...", "Now")
|
|
|
|
|
|
self.metadata_worker.start()
|
|
|
|
|
|
except Exception as e:
|
|
|
self.add_activity_item("❌", "Metadata Update", f"Failed to start: {str(e)}", "Now")
|
|
|
|
|
|
def on_artists_loaded(self, total_artists, artists_to_process):
|
|
|
"""Handle when artists are loaded and filtered"""
|
|
|
if artists_to_process == 0:
|
|
|
self.add_activity_item("✅", "Metadata Update", "All artists already have good metadata", "Now")
|
|
|
else:
|
|
|
self.add_activity_item("🎵", "Metadata Update", f"Processing {artists_to_process} of {total_artists} artists", "Now")
|
|
|
|
|
|
def stop_metadata_update(self):
|
|
|
"""Stop the metadata update process"""
|
|
|
if hasattr(self, 'metadata_worker') and self.metadata_worker.isRunning():
|
|
|
self.metadata_worker.stop()
|
|
|
self.metadata_worker.wait(3000) # Wait up to 3 seconds
|
|
|
if self.metadata_worker.isRunning():
|
|
|
self.metadata_worker.terminate()
|
|
|
|
|
|
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
|
|
self.add_activity_item("⏹️", "Metadata Update", "Stopped metadata update process", "Now")
|
|
|
|
|
|
def artist_needs_processing(self, artist):
|
|
|
"""Check if an artist needs metadata processing using smart detection"""
|
|
|
try:
|
|
|
# Check if artist has a valid photo
|
|
|
has_valid_photo = self.artist_has_valid_photo(artist)
|
|
|
|
|
|
# Check if artist has genres (more than just basic ones)
|
|
|
existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre)
|
|
|
for genre in (artist.genres or []))
|
|
|
has_good_genres = len(existing_genres) >= 2 # At least 2 genres indicates Spotify processing
|
|
|
|
|
|
# Process if missing photo OR insufficient genres
|
|
|
return not has_valid_photo or not has_good_genres
|
|
|
|
|
|
except Exception as e:
|
|
|
print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
|
|
return True # Process if we can't determine status
|
|
|
|
|
|
def artist_has_valid_photo(self, artist):
|
|
|
"""Check if artist has a valid photo"""
|
|
|
try:
|
|
|
if not hasattr(artist, 'thumb') or not artist.thumb:
|
|
|
return False
|
|
|
|
|
|
# Quick check for suspicious URLs (default Plex placeholders often contain 'default' or are very short)
|
|
|
thumb_url = str(artist.thumb)
|
|
|
if 'default' in thumb_url.lower() or len(thumb_url) < 50:
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
return False
|
|
|
|
|
|
def on_metadata_progress(self, current_artist, processed, total, percentage):
|
|
|
"""Handle metadata update progress"""
|
|
|
self.metadata_widget.update_progress(True, current_artist, processed, total, percentage)
|
|
|
|
|
|
def on_artist_updated(self, artist_name, success, details):
|
|
|
"""Handle individual artist update completion"""
|
|
|
if success:
|
|
|
self.add_activity_item("✅", "Artist Updated", f"'{artist_name}' - {details}", "Now")
|
|
|
else:
|
|
|
self.add_activity_item("❌", "Artist Failed", f"'{artist_name}' - {details}", "Now")
|
|
|
|
|
|
def on_metadata_finished(self, total_processed, successful, failed):
|
|
|
"""Handle metadata update completion"""
|
|
|
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
|
|
summary = f"Processed {total_processed} artists: {successful} updated, {failed} failed"
|
|
|
self.add_activity_item("🎵", "Metadata Complete", summary, "Now")
|
|
|
|
|
|
def on_metadata_error(self, error_message):
|
|
|
"""Handle metadata update error"""
|
|
|
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
|
|
|
self.add_activity_item("❌", "Metadata Error", error_message, "Now")
|
|
|
|
|
|
def on_service_status_updated(self, service: str, connected: bool, response_time: float, error: str):
|
|
|
"""Handle service status updates from data provider"""
|
|
|
if service in self.service_cards:
|
|
|
self.service_cards[service].update_status(connected, response_time, error)
|
|
|
|
|
|
# Only add activity item if status actually changed
|
|
|
if service not in self.previous_service_status or self.previous_service_status[service] != connected:
|
|
|
self.previous_service_status[service] = connected
|
|
|
|
|
|
status = "Connected" if connected else "Disconnected"
|
|
|
icon = "✅" if connected else "❌"
|
|
|
self.add_activity_item(icon, f"{service.capitalize()} {status}",
|
|
|
f"Response time: {response_time:.0f}ms" if connected else f"Error: {error}" if error else "Connection test completed",
|
|
|
"Now")
|
|
|
|
|
|
def on_download_stats_updated(self, active_count: int, finished_count: int, total_speed: float):
|
|
|
"""Handle download statistics updates"""
|
|
|
if 'active_downloads' in self.stats_cards:
|
|
|
self.stats_cards['active_downloads'].update_values(str(active_count), "Currently downloading")
|
|
|
|
|
|
if 'finished_downloads' in self.stats_cards:
|
|
|
self.stats_cards['finished_downloads'].update_values(str(finished_count), "Completed today")
|
|
|
|
|
|
if 'download_speed' in self.stats_cards:
|
|
|
# Format speed based on magnitude
|
|
|
if total_speed <= 0:
|
|
|
speed_text = "0 B/s"
|
|
|
elif total_speed >= 1024 * 1024: # MB/s
|
|
|
speed_text = f"{total_speed / (1024 * 1024):.1f} MB/s"
|
|
|
elif total_speed >= 1024: # KB/s
|
|
|
speed_text = f"{total_speed / 1024:.1f} KB/s"
|
|
|
else:
|
|
|
speed_text = f"{total_speed:.0f} B/s"
|
|
|
self.stats_cards['download_speed'].update_values(speed_text, "Combined speed")
|
|
|
|
|
|
def on_metadata_progress_updated(self, is_running: bool, current_artist: str, processed: int, total: int, percentage: float):
|
|
|
"""Handle metadata update progress"""
|
|
|
self.metadata_widget.update_progress(is_running, current_artist, processed, total, percentage)
|
|
|
|
|
|
def on_sync_progress_updated(self, current_playlist: str, active_syncs: int):
|
|
|
"""Handle sync progress updates"""
|
|
|
if 'active_syncs' in self.stats_cards:
|
|
|
self.stats_cards['active_syncs'].update_values(str(active_syncs), "Playlists syncing")
|
|
|
|
|
|
def on_system_stats_updated(self, uptime: str, memory: str):
|
|
|
"""Handle system statistics updates"""
|
|
|
if 'uptime' in self.stats_cards:
|
|
|
self.stats_cards['uptime'].update_values(uptime, "Application runtime")
|
|
|
|
|
|
if 'memory' in self.stats_cards:
|
|
|
self.stats_cards['memory'].update_values(memory, "Current usage")
|
|
|
|
|
|
def on_stat_card_clicked(self, card_title: str):
|
|
|
"""Handle stat card clicks for detailed views"""
|
|
|
# This can be implemented later for detailed views
|
|
|
pass
|
|
|
|
|
|
def add_activity_item(self, icon: str, title: str, subtitle: str, time_ago: str = "Now"):
|
|
|
"""Add new activity item to the feed and potentially show a toast"""
|
|
|
# Show toast for immediate user actions (if toast manager is available)
|
|
|
if hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
self._maybe_show_toast(icon, title, subtitle)
|
|
|
|
|
|
# Remove placeholder if it exists
|
|
|
if self.has_placeholder:
|
|
|
# Clear the entire layout
|
|
|
while self.activity_layout.count():
|
|
|
item = self.activity_layout.takeAt(0)
|
|
|
if item.widget():
|
|
|
item.widget().deleteLater()
|
|
|
self.has_placeholder = False
|
|
|
|
|
|
# Add separator if there are existing items
|
|
|
if self.activity_layout.count() > 0:
|
|
|
separator = QFrame()
|
|
|
separator.setFixedHeight(1)
|
|
|
separator.setStyleSheet("background: #404040;")
|
|
|
self.activity_layout.insertWidget(0, separator)
|
|
|
|
|
|
# Add new activity item at the top
|
|
|
new_item = ActivityItem(icon, title, subtitle, time_ago)
|
|
|
self.activity_layout.insertWidget(0, new_item)
|
|
|
|
|
|
# Limit to 5 most recent items (5 items + 4 separators = 9 total)
|
|
|
while self.activity_layout.count() > 9:
|
|
|
item = self.activity_layout.takeAt(self.activity_layout.count() - 1)
|
|
|
if item.widget():
|
|
|
item.widget().deleteLater()
|
|
|
|
|
|
def _maybe_show_toast(self, icon: str, title: str, subtitle: str):
|
|
|
"""Determine if this activity should show a toast notification"""
|
|
|
from ui.components.toast_manager import ToastType
|
|
|
|
|
|
# Success activities that deserve toasts
|
|
|
if icon == "✅" and any(keyword in title.lower() for keyword in ["download started", "sync completed", "complete"]):
|
|
|
self.toast_manager.success(f"{title}: {subtitle}")
|
|
|
return
|
|
|
|
|
|
if icon == "📥" and "Download Started" in title:
|
|
|
self.toast_manager.success(f"{subtitle}")
|
|
|
return
|
|
|
|
|
|
if icon == "🔍" and "Search Complete" in title:
|
|
|
self.toast_manager.info(f"{subtitle}")
|
|
|
return
|
|
|
|
|
|
# Error activities that need immediate attention
|
|
|
if icon == "❌":
|
|
|
# Skip routine background errors
|
|
|
if any(skip_term in title.lower() for skip_term in ["metadata", "connection test", "routine"]):
|
|
|
return
|
|
|
|
|
|
# Show errors for user-initiated actions
|
|
|
if any(keyword in title.lower() for keyword in ["download failed", "sync failed", "search failed"]):
|
|
|
self.toast_manager.error(f"{title}: {subtitle}")
|
|
|
return
|
|
|
|
|
|
# Warning activities
|
|
|
if icon == "⚠️":
|
|
|
self.toast_manager.warning(f"{title}: {subtitle}")
|
|
|
return
|
|
|
|
|
|
# Info activities for searches and connections
|
|
|
if icon == "🔍" and "Search Started" in title:
|
|
|
self.toast_manager.info(f"{subtitle}")
|
|
|
return
|
|
|
|
|
|
def closeEvent(self, event):
|
|
|
"""Clean up threads when dashboard is closed"""
|
|
|
self.cleanup_threads()
|
|
|
|
|
|
# Stop wishlist timers
|
|
|
if hasattr(self, 'wishlist_update_timer'):
|
|
|
self.wishlist_update_timer.stop()
|
|
|
if hasattr(self, 'wishlist_retry_timer'):
|
|
|
self.wishlist_retry_timer.stop()
|
|
|
|
|
|
# Stop the data provider timers
|
|
|
if hasattr(self.data_provider, 'download_stats_timer'):
|
|
|
self.data_provider.download_stats_timer.stop()
|
|
|
if hasattr(self.data_provider, 'system_stats_timer'):
|
|
|
self.data_provider.system_stats_timer.stop()
|
|
|
|
|
|
# Clean up database-related threads and timers (only on actual shutdown)
|
|
|
if hasattr(self, 'database_worker') and self.database_worker and self.database_worker.isRunning():
|
|
|
try:
|
|
|
self.database_worker.stop()
|
|
|
self.database_worker.wait(2000) # Give it more time
|
|
|
if self.database_worker.isRunning():
|
|
|
self.database_worker.terminate()
|
|
|
self.database_worker.deleteLater()
|
|
|
except Exception as e:
|
|
|
logger.debug(f"Error cleaning up database worker: {e}")
|
|
|
|
|
|
if hasattr(self, 'database_stats_timer') and self.database_stats_timer:
|
|
|
try:
|
|
|
self.database_stats_timer.stop()
|
|
|
except Exception as e:
|
|
|
logger.debug(f"Error stopping database stats timer: {e}")
|
|
|
|
|
|
# Clean up any running stats workers
|
|
|
if hasattr(self, '_active_stats_workers') and self._active_stats_workers:
|
|
|
try:
|
|
|
for worker in self._active_stats_workers[:]: # Copy list to avoid modification issues
|
|
|
if worker and worker.isRunning():
|
|
|
worker.stop()
|
|
|
worker.wait(1000)
|
|
|
if worker:
|
|
|
worker.deleteLater()
|
|
|
self._active_stats_workers.clear()
|
|
|
except Exception as e:
|
|
|
logger.debug(f"Error cleaning up stats workers: {e}")
|
|
|
|
|
|
# Clean up metadata worker as well (only on shutdown)
|
|
|
if hasattr(self, 'metadata_worker') and self.metadata_worker and self.metadata_worker.isRunning():
|
|
|
try:
|
|
|
self.metadata_worker.stop()
|
|
|
self.metadata_worker.wait(2000) # Give it more time
|
|
|
if self.metadata_worker.isRunning():
|
|
|
self.metadata_worker.terminate()
|
|
|
self.metadata_worker.deleteLater()
|
|
|
except Exception as e:
|
|
|
logger.debug(f"Error cleaning up metadata worker: {e}")
|
|
|
|
|
|
super().closeEvent(event)
|
|
|
|
|
|
def cleanup_threads(self):
|
|
|
"""Clean up all running test threads"""
|
|
|
if hasattr(self.data_provider, '_test_threads'):
|
|
|
for service, thread in self.data_provider._test_threads.items():
|
|
|
if thread.isRunning():
|
|
|
thread.quit()
|
|
|
thread.wait(1000) # Wait up to 1 second
|
|
|
thread.deleteLater()
|
|
|
self.data_provider._test_threads.clear()
|
|
|
|
|
|
|
|
|
|
|
|
def on_wishlist_button_clicked(self):
|
|
|
"""
|
|
|
Shows the persistent wishlist modal, creating it if it doesn't exist yet.
|
|
|
If a search is in progress, this will reveal the live state.
|
|
|
"""
|
|
|
try:
|
|
|
# If the modal doesn't exist and there are no tracks, show info and return.
|
|
|
if self.wishlist_download_modal is None and self.wishlist_service.get_wishlist_count() == 0:
|
|
|
QMessageBox.information(self, "Wishlist", "Your wishlist is empty!")
|
|
|
return
|
|
|
|
|
|
# Ensure the modal instance exists before trying to show it.
|
|
|
if not self._ensure_wishlist_modal_exists():
|
|
|
return # Modal creation failed, error message already shown.
|
|
|
|
|
|
# Now that we're sure the modal exists, just show it.
|
|
|
self.wishlist_download_modal.show()
|
|
|
self.wishlist_download_modal.activateWindow()
|
|
|
self.wishlist_download_modal.raise_()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error opening wishlist: {e}")
|
|
|
QMessageBox.critical(self, "Error", f"Failed to open wishlist: {str(e)}")
|
|
|
|
|
|
|
|
|
def update_wishlist_button_count(self):
|
|
|
"""Update the wishlist button with current count"""
|
|
|
try:
|
|
|
count = self.wishlist_service.get_wishlist_count()
|
|
|
|
|
|
if hasattr(self, 'wishlist_button'):
|
|
|
self.wishlist_button.setText(f"🎵 Wishlist ({count})")
|
|
|
|
|
|
# Enable/disable button based on count
|
|
|
if count == 0:
|
|
|
self.wishlist_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #404040;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #888888;
|
|
|
font-size: 12px;
|
|
|
font-weight: bold;
|
|
|
padding: 8px 16px;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #505050;
|
|
|
color: #999999;
|
|
|
}
|
|
|
""")
|
|
|
else:
|
|
|
self.wishlist_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #1db954;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #000000;
|
|
|
font-size: 12px;
|
|
|
font-weight: bold;
|
|
|
padding: 8px 16px;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #1ed760;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #169c46;
|
|
|
}
|
|
|
""")
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error updating wishlist button count: {e}")
|
|
|
|
|
|
def on_watchlist_button_clicked(self):
|
|
|
"""Show the watchlist status modal"""
|
|
|
try:
|
|
|
# Check if any artists are in watchlist
|
|
|
database = get_database()
|
|
|
watchlist_count = database.get_watchlist_count()
|
|
|
|
|
|
if watchlist_count == 0:
|
|
|
QMessageBox.information(self, "Watchlist", "Your watchlist is empty!\n\nAdd artists to your watchlist from the Artists page to monitor them for new releases.")
|
|
|
return
|
|
|
|
|
|
# Create and show watchlist status modal
|
|
|
from ui.components.watchlist_status_modal import WatchlistStatusModal
|
|
|
spotify_client = self.service_clients.get('spotify_client')
|
|
|
|
|
|
# Always recreate the modal to ensure fresh state and signal connections
|
|
|
if hasattr(self, 'watchlist_status_modal') and self.watchlist_status_modal:
|
|
|
# Disconnect old signals to prevent duplicates
|
|
|
try:
|
|
|
self.watchlist_scan_started.disconnect(self.watchlist_status_modal.on_background_scan_started)
|
|
|
self.watchlist_scan_completed.disconnect(self.watchlist_status_modal.on_background_scan_completed)
|
|
|
except:
|
|
|
pass # Ignore if signals weren't connected
|
|
|
self.watchlist_status_modal.deleteLater()
|
|
|
|
|
|
self.watchlist_status_modal = WatchlistStatusModal(self, spotify_client)
|
|
|
|
|
|
# Connect dashboard signals to modal for live updates during background scans
|
|
|
self.watchlist_scan_started.connect(self.watchlist_status_modal.on_background_scan_started)
|
|
|
self.watchlist_scan_completed.connect(self.watchlist_status_modal.on_background_scan_completed)
|
|
|
|
|
|
# If a background scan is currently running, connect the detailed progress signals
|
|
|
if hasattr(self, 'background_watchlist_worker') and self.background_watchlist_worker:
|
|
|
try:
|
|
|
self.background_watchlist_worker.signals.scan_started.connect(self.watchlist_status_modal.on_scan_started)
|
|
|
self.background_watchlist_worker.signals.artist_scan_started.connect(self.watchlist_status_modal.on_artist_scan_started)
|
|
|
self.background_watchlist_worker.signals.artist_totals_discovered.connect(self.watchlist_status_modal.on_artist_totals_discovered)
|
|
|
self.background_watchlist_worker.signals.album_scan_started.connect(self.watchlist_status_modal.on_album_scan_started)
|
|
|
self.background_watchlist_worker.signals.track_check_started.connect(self.watchlist_status_modal.on_track_check_started)
|
|
|
self.background_watchlist_worker.signals.release_completed.connect(self.watchlist_status_modal.on_release_completed)
|
|
|
self.background_watchlist_worker.signals.artist_scan_completed.connect(self.watchlist_status_modal.on_artist_scan_completed)
|
|
|
except Exception as e:
|
|
|
logger.debug(f"Background worker signals already connected or unavailable: {e}")
|
|
|
|
|
|
# Always refresh data when showing the modal
|
|
|
self.watchlist_status_modal.load_watchlist_data()
|
|
|
|
|
|
self.watchlist_status_modal.show()
|
|
|
self.watchlist_status_modal.activateWindow()
|
|
|
self.watchlist_status_modal.raise_()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error opening watchlist status: {e}")
|
|
|
QMessageBox.critical(self, "Error", f"Failed to open watchlist status: {str(e)}")
|
|
|
|
|
|
def update_watchlist_button_count(self):
|
|
|
"""Update the watchlist button with current count"""
|
|
|
try:
|
|
|
database = get_database()
|
|
|
count = database.get_watchlist_count()
|
|
|
|
|
|
if hasattr(self, 'watchlist_button'):
|
|
|
self.watchlist_button.setText(f"👁️ Watchlist ({count})")
|
|
|
|
|
|
# Enable/disable button based on count
|
|
|
if count == 0:
|
|
|
self.watchlist_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #404040;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #888888;
|
|
|
font-size: 12px;
|
|
|
font-weight: bold;
|
|
|
padding: 8px 16px;
|
|
|
}
|
|
|
""")
|
|
|
else:
|
|
|
self.watchlist_button.setStyleSheet("""
|
|
|
QPushButton {
|
|
|
background: #ffc107;
|
|
|
border: none;
|
|
|
border-radius: 22px;
|
|
|
color: #000000;
|
|
|
font-size: 12px;
|
|
|
font-weight: bold;
|
|
|
padding: 8px 16px;
|
|
|
}
|
|
|
QPushButton:hover {
|
|
|
background: #ffca28;
|
|
|
}
|
|
|
QPushButton:pressed {
|
|
|
background: #ff8f00;
|
|
|
}
|
|
|
""")
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error updating watchlist button count: {e}")
|
|
|
|
|
|
def process_wishlist_automatically(self):
|
|
|
"""Automatically process wishlist tracks in the background."""
|
|
|
try:
|
|
|
if self.auto_processing_wishlist:
|
|
|
logger.debug("Wishlist auto-processing already running, skipping.")
|
|
|
# Reschedule the next check
|
|
|
self.wishlist_retry_timer.start(600000) # 10 minutes
|
|
|
return
|
|
|
|
|
|
if self.wishlist_service.get_wishlist_count() == 0:
|
|
|
logger.debug("No tracks in wishlist for auto-processing.")
|
|
|
# Reschedule the next check
|
|
|
self.wishlist_retry_timer.start(600000) # 10 minutes
|
|
|
return
|
|
|
|
|
|
logger.info("Starting automatic wishlist processing...")
|
|
|
# Use the central method to start the process
|
|
|
self.start_wishlist_search_process()
|
|
|
|
|
|
# The on_all_downloads_complete method will handle rescheduling the timer.
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error starting automatic wishlist processing: {e}")
|
|
|
self.auto_processing_wishlist = False
|
|
|
# Reschedule on error
|
|
|
self.wishlist_retry_timer.start(600000) # 10 minutes
|
|
|
|
|
|
def on_auto_wishlist_processing_complete(self, successful, failed, total):
|
|
|
"""Handle completion of automatic wishlist processing"""
|
|
|
try:
|
|
|
self.auto_processing_wishlist = False
|
|
|
|
|
|
logger.info(f"Automatic wishlist processing complete: {successful} successful, {failed} failed, {total} total")
|
|
|
|
|
|
# Update button count since tracks may have been removed
|
|
|
self.update_wishlist_button_count()
|
|
|
|
|
|
# Refresh any open wishlist modals
|
|
|
for widget in QApplication.instance().allWidgets():
|
|
|
if isinstance(widget, DownloadMissingWishlistTracksModal) and widget.isVisible():
|
|
|
widget.refresh_if_auto_processing_complete()
|
|
|
|
|
|
# Show toast notification if there were successful downloads
|
|
|
if successful > 0 and hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
message = f"Found {successful} wishlist track{'s' if successful != 1 else ''} automatically!"
|
|
|
self.toast_manager.success(message)
|
|
|
|
|
|
# Schedule next wishlist processing in 10 minutes
|
|
|
if hasattr(self, 'wishlist_retry_timer') and self.wishlist_retry_timer:
|
|
|
logger.info("Scheduling next automatic wishlist processing in 10 minutes")
|
|
|
self.wishlist_retry_timer.start(600000) # 10 minutes (600000 ms)
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error handling automatic wishlist processing completion: {e}")
|
|
|
|
|
|
def on_auto_wishlist_processing_error(self, error_message):
|
|
|
"""Handle error in automatic wishlist processing"""
|
|
|
try:
|
|
|
self.auto_processing_wishlist = False
|
|
|
logger.error(f"Automatic wishlist processing failed: {error_message}")
|
|
|
|
|
|
# Schedule next wishlist processing in 60 minutes even after error
|
|
|
if hasattr(self, 'wishlist_retry_timer') and self.wishlist_retry_timer:
|
|
|
logger.info("Scheduling next automatic wishlist processing in 60 minutes (after error)")
|
|
|
self.wishlist_retry_timer.start(600000) # 10 minutes (600000 ms)
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error handling automatic wishlist processing error: {e}")
|
|
|
|
|
|
def process_watchlist_automatically(self):
|
|
|
"""Automatically scan watchlist artists for new releases"""
|
|
|
try:
|
|
|
if self.auto_processing_watchlist:
|
|
|
logger.debug("Watchlist auto-scanning already running, skipping.")
|
|
|
# Reschedule the next check
|
|
|
self.watchlist_scan_timer.start(600000) # 10 minutes
|
|
|
return
|
|
|
|
|
|
# Check if there's an ongoing manual scan from the watchlist modal
|
|
|
from ui.components.watchlist_status_modal import WatchlistStatusModal
|
|
|
if (WatchlistStatusModal._shared_scan_worker
|
|
|
and WatchlistStatusModal._shared_scan_worker.isRunning()):
|
|
|
logger.debug("Manual watchlist scan already running, skipping automatic scan.")
|
|
|
# Reschedule the next check
|
|
|
self.watchlist_scan_timer.start(600000) # 10 minutes
|
|
|
return
|
|
|
|
|
|
database = get_database()
|
|
|
watchlist_count = database.get_watchlist_count()
|
|
|
|
|
|
if watchlist_count == 0:
|
|
|
logger.debug("No artists in watchlist for auto-scanning.")
|
|
|
# Reschedule the next check
|
|
|
self.watchlist_scan_timer.start(600000) # 10 minutes
|
|
|
return
|
|
|
|
|
|
spotify_client = self.service_clients.get('spotify_client')
|
|
|
if not spotify_client or not spotify_client.is_authenticated():
|
|
|
logger.warning("Spotify client not available for watchlist scanning")
|
|
|
# Reschedule the next check
|
|
|
self.watchlist_scan_timer.start(600000) # 10 minutes
|
|
|
return
|
|
|
|
|
|
logger.info(f"Starting automatic watchlist scanning for {watchlist_count} artists...")
|
|
|
self.auto_processing_watchlist = True
|
|
|
|
|
|
# Emit signal to any open modal
|
|
|
self.watchlist_scan_started.emit()
|
|
|
|
|
|
# Start background watchlist scan
|
|
|
self.background_watchlist_worker = AutoWatchlistScanWorker(spotify_client)
|
|
|
self.background_watchlist_worker.signals.scan_complete.connect(self.on_auto_watchlist_scan_complete)
|
|
|
self.background_watchlist_worker.signals.scan_error.connect(self.on_auto_watchlist_scan_error)
|
|
|
|
|
|
# Connect detailed progress signals to modal if it's open
|
|
|
if hasattr(self, 'watchlist_status_modal') and self.watchlist_status_modal and self.watchlist_status_modal.isVisible():
|
|
|
self.background_watchlist_worker.signals.scan_started.connect(self.watchlist_status_modal.on_scan_started)
|
|
|
self.background_watchlist_worker.signals.artist_scan_started.connect(self.watchlist_status_modal.on_artist_scan_started)
|
|
|
self.background_watchlist_worker.signals.artist_totals_discovered.connect(self.watchlist_status_modal.on_artist_totals_discovered)
|
|
|
self.background_watchlist_worker.signals.album_scan_started.connect(self.watchlist_status_modal.on_album_scan_started)
|
|
|
self.background_watchlist_worker.signals.track_check_started.connect(self.watchlist_status_modal.on_track_check_started)
|
|
|
self.background_watchlist_worker.signals.release_completed.connect(self.watchlist_status_modal.on_release_completed)
|
|
|
self.background_watchlist_worker.signals.artist_scan_completed.connect(self.watchlist_status_modal.on_artist_scan_completed)
|
|
|
|
|
|
QThreadPool.globalInstance().start(self.background_watchlist_worker)
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error starting automatic watchlist scanning: {e}")
|
|
|
self.auto_processing_watchlist = False
|
|
|
# Reschedule on error
|
|
|
self.watchlist_scan_timer.start(600000) # 10 minutes
|
|
|
|
|
|
def on_auto_watchlist_scan_complete(self, total_artists: int, total_new_tracks: int, total_added_to_wishlist: int):
|
|
|
"""Handle completion of automatic watchlist scanning"""
|
|
|
try:
|
|
|
self.auto_processing_watchlist = False
|
|
|
|
|
|
# Clear background worker reference
|
|
|
if hasattr(self, 'background_watchlist_worker'):
|
|
|
self.background_watchlist_worker = None
|
|
|
|
|
|
logger.info(f"Automatic watchlist scan complete: {total_artists} artists, {total_new_tracks} new tracks found, {total_added_to_wishlist} added to wishlist")
|
|
|
|
|
|
# Emit signal to any open modal
|
|
|
self.watchlist_scan_completed.emit(total_artists, total_new_tracks, total_added_to_wishlist)
|
|
|
|
|
|
# Update button counts since watchlist and wishlist may have changed
|
|
|
self.update_watchlist_button_count()
|
|
|
self.update_wishlist_button_count()
|
|
|
|
|
|
# Show toast notification if new tracks were found
|
|
|
if total_new_tracks > 0 and hasattr(self, 'toast_manager') and self.toast_manager:
|
|
|
message = f"Found {total_new_tracks} new track{'s' if total_new_tracks != 1 else ''} from watched artists!"
|
|
|
self.toast_manager.success(message)
|
|
|
|
|
|
# Schedule next watchlist scan in 60 minutes
|
|
|
if hasattr(self, 'watchlist_scan_timer') and self.watchlist_scan_timer:
|
|
|
logger.info("Scheduling next automatic watchlist scan in 60 minutes")
|
|
|
self.watchlist_scan_timer.start(600000) # 10 minutes
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error handling automatic watchlist scan completion: {e}")
|
|
|
|
|
|
def on_auto_watchlist_scan_error(self, error_message: str):
|
|
|
"""Handle error in automatic watchlist scanning"""
|
|
|
try:
|
|
|
self.auto_processing_watchlist = False
|
|
|
|
|
|
# Clear background worker reference
|
|
|
if hasattr(self, 'background_watchlist_worker'):
|
|
|
self.background_watchlist_worker = None
|
|
|
logger.error(f"Automatic watchlist scanning failed: {error_message}")
|
|
|
|
|
|
# Schedule next watchlist scan in 60 minutes even after error
|
|
|
if hasattr(self, 'watchlist_scan_timer') and self.watchlist_scan_timer:
|
|
|
logger.info("Scheduling next automatic watchlist scan in 60 minutes (after error)")
|
|
|
self.watchlist_scan_timer.start(600000) # 10 minutes
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error handling automatic watchlist scan error: {e}")
|
|
|
|
|
|
|
|
|
class AutoWatchlistScanWorker(QRunnable):
|
|
|
"""Background worker for automatic watchlist scanning with detailed progress updates"""
|
|
|
|
|
|
class Signals(QObject):
|
|
|
# Summary signals for dashboard
|
|
|
scan_complete = pyqtSignal(int, int, int) # total_artists, total_new_tracks, total_added_to_wishlist
|
|
|
scan_error = pyqtSignal(str) # error_message
|
|
|
|
|
|
# Detailed progress signals for modal (same as WatchlistScanWorker)
|
|
|
scan_started = pyqtSignal()
|
|
|
artist_scan_started = pyqtSignal(str) # artist_name
|
|
|
artist_totals_discovered = pyqtSignal(str, int, int) # artist_name, total_singles_eps_releases, total_albums
|
|
|
album_scan_started = pyqtSignal(str, str, int) # artist_name, album_name, total_tracks
|
|
|
track_check_started = pyqtSignal(str, str, str) # artist_name, album_name, track_name
|
|
|
release_completed = pyqtSignal(str, str, int) # artist_name, album_name, total_tracks
|
|
|
artist_scan_completed = pyqtSignal(str, int, int, bool) # artist_name, albums_checked, new_tracks, success
|
|
|
|
|
|
def __init__(self, spotify_client):
|
|
|
super().__init__()
|
|
|
self.spotify_client = spotify_client
|
|
|
self.signals = self.Signals()
|
|
|
self.should_stop = False
|
|
|
|
|
|
def stop(self):
|
|
|
"""Stop the scanning process"""
|
|
|
self.should_stop = True
|
|
|
|
|
|
def run(self):
|
|
|
"""Run the watchlist scan with detailed progress updates"""
|
|
|
try:
|
|
|
logger.info("Starting background watchlist scan...")
|
|
|
self.signals.scan_started.emit()
|
|
|
|
|
|
# Get all watchlist artists
|
|
|
from database.music_database import get_database
|
|
|
database = get_database()
|
|
|
watchlist_artists = database.get_watchlist_artists()
|
|
|
|
|
|
scan_results = []
|
|
|
|
|
|
for artist in watchlist_artists:
|
|
|
if self.should_stop:
|
|
|
break
|
|
|
|
|
|
self.signals.artist_scan_started.emit(artist.artist_name)
|
|
|
|
|
|
# Perform detailed scan with progress updates
|
|
|
result = self._scan_artist_with_progress(artist, database)
|
|
|
scan_results.append(result)
|
|
|
|
|
|
self.signals.artist_scan_completed.emit(
|
|
|
artist.artist_name,
|
|
|
result.albums_checked,
|
|
|
result.new_tracks_found,
|
|
|
result.success
|
|
|
)
|
|
|
|
|
|
# Calculate totals
|
|
|
successful_scans = [r for r in scan_results if r.success]
|
|
|
total_artists = len(successful_scans)
|
|
|
total_new_tracks = sum(r.new_tracks_found for r in successful_scans)
|
|
|
total_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans)
|
|
|
|
|
|
self.signals.scan_complete.emit(total_artists, total_new_tracks, total_added_to_wishlist)
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error in background watchlist scan: {e}")
|
|
|
self.signals.scan_error.emit(str(e))
|
|
|
|
|
|
def _scan_artist_with_progress(self, watchlist_artist, database):
|
|
|
"""Scan artist with detailed progress emissions (same as WatchlistScanWorker)"""
|
|
|
try:
|
|
|
# Get watchlist scanner
|
|
|
from core.watchlist_scanner import get_watchlist_scanner, ScanResult
|
|
|
scanner = get_watchlist_scanner(self.spotify_client)
|
|
|
|
|
|
# Get artist discography
|
|
|
albums = scanner.get_artist_discography(
|
|
|
watchlist_artist.spotify_artist_id,
|
|
|
watchlist_artist.last_scan_timestamp
|
|
|
)
|
|
|
|
|
|
if albums is None:
|
|
|
return ScanResult(
|
|
|
artist_name=watchlist_artist.artist_name,
|
|
|
spotify_artist_id=watchlist_artist.spotify_artist_id,
|
|
|
albums_checked=0,
|
|
|
new_tracks_found=0,
|
|
|
tracks_added_to_wishlist=0,
|
|
|
success=False,
|
|
|
error_message="Failed to get artist discography from Spotify"
|
|
|
)
|
|
|
|
|
|
# Analyze the albums list to get total counts upfront
|
|
|
total_singles_eps_releases = 0
|
|
|
total_albums = 0
|
|
|
|
|
|
for album in albums:
|
|
|
try:
|
|
|
# Get full album data to count tracks
|
|
|
album_data = self.spotify_client.get_album(album.id)
|
|
|
if not album_data or 'tracks' not in album_data:
|
|
|
continue
|
|
|
|
|
|
track_count = len(album_data['tracks'].get('items', []))
|
|
|
|
|
|
# Categorize based on track count - COUNT RELEASES not tracks
|
|
|
if track_count >= 4:
|
|
|
total_albums += 1
|
|
|
else:
|
|
|
total_singles_eps_releases += 1 # Count the release, not the tracks
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.warning(f"Error analyzing album {album.name} for totals: {e}")
|
|
|
continue
|
|
|
|
|
|
# Emit the discovered totals
|
|
|
self.signals.artist_totals_discovered.emit(
|
|
|
watchlist_artist.artist_name,
|
|
|
total_singles_eps_releases,
|
|
|
total_albums
|
|
|
)
|
|
|
|
|
|
new_tracks_found = 0
|
|
|
tracks_added_to_wishlist = 0
|
|
|
|
|
|
for album in albums:
|
|
|
if self.should_stop:
|
|
|
break
|
|
|
|
|
|
try:
|
|
|
# Get full album data with tracks first
|
|
|
album_data = self.spotify_client.get_album(album.id)
|
|
|
if not album_data or 'tracks' not in album_data or not album_data['tracks'].get('items'):
|
|
|
continue
|
|
|
|
|
|
tracks = album_data['tracks']['items']
|
|
|
|
|
|
# Emit album progress with track count
|
|
|
self.signals.album_scan_started.emit(watchlist_artist.artist_name, album.name, len(tracks))
|
|
|
|
|
|
# Check each track
|
|
|
for track in tracks:
|
|
|
if self.should_stop:
|
|
|
break
|
|
|
|
|
|
# Emit track check progress
|
|
|
self.signals.track_check_started.emit(
|
|
|
watchlist_artist.artist_name,
|
|
|
album_data.get('name', 'Unknown'),
|
|
|
track.get('name', 'Unknown')
|
|
|
)
|
|
|
|
|
|
if scanner.is_track_missing_from_library(track):
|
|
|
new_tracks_found += 1
|
|
|
|
|
|
# Add to wishlist
|
|
|
if scanner.add_track_to_wishlist(track, album_data, watchlist_artist):
|
|
|
tracks_added_to_wishlist += 1
|
|
|
|
|
|
# Emit release completion signal
|
|
|
self.signals.release_completed.emit(watchlist_artist.artist_name, album.name, len(tracks))
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.warning(f"Error checking album {album.name}: {e}")
|
|
|
continue
|
|
|
|
|
|
# Update last scan timestamp
|
|
|
scanner.update_artist_scan_timestamp(watchlist_artist.spotify_artist_id)
|
|
|
|
|
|
return ScanResult(
|
|
|
artist_name=watchlist_artist.artist_name,
|
|
|
spotify_artist_id=watchlist_artist.spotify_artist_id,
|
|
|
albums_checked=len(albums),
|
|
|
new_tracks_found=new_tracks_found,
|
|
|
tracks_added_to_wishlist=tracks_added_to_wishlist,
|
|
|
success=True
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error scanning artist {watchlist_artist.artist_name}: {e}")
|
|
|
return ScanResult(
|
|
|
artist_name=watchlist_artist.artist_name,
|
|
|
spotify_artist_id=watchlist_artist.spotify_artist_id,
|
|
|
albums_checked=0,
|
|
|
new_tracks_found=0,
|
|
|
tracks_added_to_wishlist=0,
|
|
|
success=False,
|
|
|
error_message=str(e)
|
|
|
)
|
|
|
|
|
|
|
|
|
class AutoWishlistProcessorWorker(QRunnable):
|
|
|
"""Background worker for automatic wishlist processing"""
|
|
|
|
|
|
class Signals(QObject):
|
|
|
processing_complete = pyqtSignal(int, int, int) # successful, failed, total
|
|
|
processing_error = pyqtSignal(str) # error_message
|
|
|
|
|
|
def __init__(self, wishlist_service, spotify_client, plex_client, soulseek_client, downloads_page):
|
|
|
super().__init__()
|
|
|
self.wishlist_service = wishlist_service
|
|
|
self.spotify_client = spotify_client
|
|
|
self.plex_client = plex_client
|
|
|
self.soulseek_client = soulseek_client
|
|
|
self.downloads_page = downloads_page
|
|
|
self.signals = self.Signals()
|
|
|
|
|
|
def run(self):
|
|
|
"""Run automatic wishlist processing"""
|
|
|
try:
|
|
|
# Get quality preference
|
|
|
from config.settings import config_manager
|
|
|
quality_preference = config_manager.get_quality_preference()
|
|
|
|
|
|
# Get all wishlist tracks (no limit - process everything)
|
|
|
wishlist_tracks = self.wishlist_service.get_wishlist_tracks_for_download()
|
|
|
|
|
|
if not wishlist_tracks:
|
|
|
self.signals.processing_complete.emit(0, 0, 0)
|
|
|
return
|
|
|
|
|
|
total_tracks = len(wishlist_tracks)
|
|
|
successful_downloads = 0
|
|
|
failed_downloads = 0
|
|
|
|
|
|
logger.info(f"Processing {total_tracks} wishlist tracks automatically")
|
|
|
|
|
|
# Process each track
|
|
|
for track_data in wishlist_tracks:
|
|
|
try:
|
|
|
# Create search query
|
|
|
artist_name = track_data.get('artists', [{}])[0].get('name', '') if track_data.get('artists') else ''
|
|
|
track_name = track_data.get('name', '')
|
|
|
|
|
|
if not track_name:
|
|
|
failed_downloads += 1
|
|
|
continue
|
|
|
|
|
|
query = f"{artist_name} {track_name}".strip()
|
|
|
if not query:
|
|
|
failed_downloads += 1
|
|
|
continue
|
|
|
|
|
|
# Attempt download
|
|
|
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, quality_preference)
|
|
|
)
|
|
|
|
|
|
track_id = track_data.get('spotify_track_id')
|
|
|
|
|
|
if download_id and track_id:
|
|
|
# Mark as successful (removes from wishlist)
|
|
|
self.wishlist_service.mark_track_download_result(track_id, success=True)
|
|
|
successful_downloads += 1
|
|
|
logger.info(f"Auto-downloaded wishlist track: '{track_name}' by {artist_name}")
|
|
|
else:
|
|
|
# Mark as failed (increment retry count)
|
|
|
if track_id:
|
|
|
self.wishlist_service.mark_track_download_result(track_id, success=False, error_message="No search results found")
|
|
|
failed_downloads += 1
|
|
|
|
|
|
finally:
|
|
|
loop.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error processing wishlist track '{track_name}': {e}")
|
|
|
|
|
|
# Mark as failed
|
|
|
track_id = track_data.get('spotify_track_id')
|
|
|
if track_id:
|
|
|
self.wishlist_service.mark_track_download_result(track_id, success=False, error_message=str(e))
|
|
|
failed_downloads += 1
|
|
|
|
|
|
# Emit completion
|
|
|
self.signals.processing_complete.emit(successful_downloads, failed_downloads, total_tracks)
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Critical error in automatic wishlist processing: {e}")
|
|
|
self.signals.processing_error.emit(str(e))
|
|
|
|
|
|
# Worker is complete - no cleanup needed for this simple background task
|