This is needed if users manually correct failed matches on plex when songs are scanned in. If a db sync is done after the scan but before the manual modification,
1924 lines
No EOL
79 KiB
Python
1924 lines
No EOL
79 KiB
Python
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
|
QFrame, QGridLayout, QScrollArea, QSizePolicy, QPushButton,
|
|
QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout, QComboBox)
|
|
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject
|
|
from PyQt6.QtGui import QFont, QPalette, QColor
|
|
import time
|
|
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
|
|
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 utils.logging_config import get_logger
|
|
|
|
logger = get_logger("dashboard")
|
|
|
|
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': spotify_client,
|
|
'plex': plex_client,
|
|
'soulseek': 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())}")
|
|
|
|
if service not in self.service_clients:
|
|
print(f"DEBUG: Service {service} not found in service_clients")
|
|
return
|
|
|
|
print(f"DEBUG: Service client for {service}: {self.service_clients[service]}")
|
|
|
|
# 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[service])
|
|
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):
|
|
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()
|
|
|
|
# Initialize list to track active stats workers
|
|
self._active_stats_workers = []
|
|
|
|
# Load initial database statistics (with delay to avoid startup issues)
|
|
QTimer.singleShot(1000, self.refresh_database_statistics)
|
|
|
|
def set_service_clients(self, spotify_client, plex_client, soulseek_client):
|
|
"""Called from main window to provide service client references"""
|
|
self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client)
|
|
|
|
def set_page_references(self, downloads_page, sync_page):
|
|
"""Called from main window to provide page references for live data"""
|
|
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 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()
|
|
layout = QVBoxLayout(header)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
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;")
|
|
|
|
layout.addWidget(welcome_label)
|
|
layout.addWidget(subtitle_label)
|
|
|
|
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"""
|
|
if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex'):
|
|
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()
|
|
|
|
# Start the database update worker
|
|
self.database_worker = DatabaseUpdateWorker(
|
|
self.data_provider.service_clients['plex'],
|
|
"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 _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"""
|
|
if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex'):
|
|
self.add_activity_item("❌", "Metadata Update", "Plex client not available", "Now")
|
|
return
|
|
|
|
if not self.data_provider.service_clients.get('spotify'):
|
|
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'],
|
|
self.data_provider.service_clients['spotify'],
|
|
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()
|
|
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()
|
|
|
|
# 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
|
|
if hasattr(self, 'database_worker') and self.database_worker.isRunning():
|
|
self.database_worker.stop()
|
|
self.database_worker.wait(1000)
|
|
self.database_worker.deleteLater()
|
|
|
|
if hasattr(self, 'database_stats_timer'):
|
|
self.database_stats_timer.stop()
|
|
|
|
# Clean up any running stats workers (keep track of them)
|
|
if hasattr(self, '_active_stats_workers'):
|
|
for worker in self._active_stats_workers:
|
|
if worker.isRunning():
|
|
worker.stop()
|
|
worker.wait(1000)
|
|
worker.deleteLater()
|
|
self._active_stats_workers.clear()
|
|
|
|
# Clean up metadata worker as well
|
|
if hasattr(self, 'metadata_worker') and self.metadata_worker.isRunning():
|
|
self.metadata_worker.stop()
|
|
self.metadata_worker.wait(1000)
|
|
self.metadata_worker.deleteLater() |