diff --git a/core/__pycache__/database_update_worker.cpython-310.pyc b/core/__pycache__/database_update_worker.cpython-310.pyc new file mode 100644 index 00000000..8281902c Binary files /dev/null and b/core/__pycache__/database_update_worker.cpython-310.pyc differ diff --git a/core/__pycache__/database_update_worker.cpython-312.pyc b/core/__pycache__/database_update_worker.cpython-312.pyc new file mode 100644 index 00000000..79c01f31 Binary files /dev/null and b/core/__pycache__/database_update_worker.cpython-312.pyc differ diff --git a/core/database_update_worker.py b/core/database_update_worker.py new file mode 100644 index 00000000..5c81ed25 --- /dev/null +++ b/core/database_update_worker.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 + +from PyQt6.QtCore import QThread, pyqtSignal +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Optional, List +from datetime import datetime +import time + +from database import get_database, MusicDatabase +from utils.logging_config import get_logger + +logger = get_logger("database_update_worker") + +class DatabaseUpdateWorker(QThread): + """Worker thread for updating SoulSync database with Plex library data""" + + # Signals for progress reporting + progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage + artist_processed = pyqtSignal(str, bool, str, int, int) # artist_name, success, details, albums_count, tracks_count + finished = pyqtSignal(int, int, int, int, int) # total_artists, total_albums, total_tracks, successful, failed + error = pyqtSignal(str) # error_message + phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks) + + def __init__(self, plex_client, database_path: str = "database/music_library.db", full_refresh: bool = False): + super().__init__() + self.plex_client = plex_client + self.database_path = database_path + self.full_refresh = full_refresh + self.should_stop = False + + # Statistics tracking + self.processed_artists = 0 + self.processed_albums = 0 + self.processed_tracks = 0 + self.successful_operations = 0 + self.failed_operations = 0 + + # Threading control + self.max_workers = 3 # Conservative to avoid overwhelming Plex API + self.thread_lock = threading.Lock() + + # Database instance + self.database: Optional[MusicDatabase] = None + + def stop(self): + """Stop the database update process""" + self.should_stop = True + + def run(self): + """Main worker thread execution""" + try: + # Initialize database + self.database = get_database(self.database_path) + + if self.full_refresh: + logger.info("Performing full database refresh - clearing existing data") + self.database.clear_all_data() + # For full refresh, use the old method (all artists) + artists_to_process = self._get_all_artists() + if not artists_to_process: + self.error.emit("No artists found in Plex library or connection failed") + return + logger.info(f"Full refresh: Found {len(artists_to_process)} artists in Plex library") + else: + logger.info("Performing smart incremental update - checking recently added content") + # For incremental, use smart recent-first approach + self.phase_changed.emit("Finding recently added content...") + artists_to_process = self._get_artists_for_incremental_update() + if not artists_to_process: + logger.info("No new content found - database is up to date") + self.finished.emit(0, 0, 0, 0, 0) + return + logger.info(f"Incremental update: Found {len(artists_to_process)} artists to process") + + # Phase 2: Process artists and their albums/tracks + self.phase_changed.emit("Processing artists, albums, and tracks...") + self._process_all_artists(artists_to_process) + + # Emit final results + self.finished.emit( + self.processed_artists, + self.processed_albums, + self.processed_tracks, + self.successful_operations, + self.failed_operations + ) + + update_type = "Full refresh" if self.full_refresh else "Incremental update" + logger.info(f"{update_type} completed: {self.processed_artists} artists, " + f"{self.processed_albums} albums, {self.processed_tracks} tracks processed") + + except Exception as e: + logger.error(f"Database update failed: {str(e)}") + self.error.emit(f"Database update failed: {str(e)}") + + def _get_all_artists(self) -> List: + """Get all artists from Plex library""" + try: + if not self.plex_client.ensure_connection(): + logger.error("Could not connect to Plex server") + return [] + + artists = self.plex_client.get_all_artists() + return artists + + except Exception as e: + logger.error(f"Error getting artists from Plex: {e}") + return [] + + def _get_artists_for_incremental_update(self) -> List: + """Get artists that need processing for incremental update using smart early-stopping logic""" + try: + if not self.plex_client.ensure_connection(): + logger.error("Could not connect to Plex server") + return [] + + if not self.plex_client.music_library: + logger.error("No music library found in Plex") + return [] + + # Strategy: Get recently added albums and extract artists from them + # Process artists in reverse chronological order until we hit one that's already current + + logger.info("Getting recently added albums to find new artists...") + + # Get recently added albums (up to 500 to cast a wide net) + try: + recent_albums = self.plex_client.music_library.recentlyAdded(maxresults=500) + logger.info(f"Found {len(recent_albums)} recently added albums") + except Exception as e: + logger.warning(f"Could not get recently added albums: {e}") + # Fallback: get recently added tracks instead + try: + recent_tracks = self.plex_client.music_library.recentlyAdded(libtype='track', maxresults=1000) + logger.info(f"Fallback: Found {len(recent_tracks)} recently added tracks") + # Extract albums from tracks + recent_albums = [] + seen_albums = set() + for track in recent_tracks: + try: + album = track.album() + if album and album.ratingKey not in seen_albums: + recent_albums.append(album) + seen_albums.add(album.ratingKey) + except: + continue + logger.info(f"Extracted {len(recent_albums)} unique albums from tracks") + except Exception as e2: + logger.error(f"Could not get recently added content: {e2}") + return [] + + if not recent_albums: + logger.info("No recently added albums found") + return [] + + # Sort albums by added date (newest first) + try: + recent_albums.sort(key=lambda x: getattr(x, 'addedAt', 0), reverse=True) + logger.info("Sorted albums by recently added date (newest first)") + except Exception as e: + logger.warning(f"Could not sort albums by date: {e}") + + # Extract artists from recent albums with early stopping logic + artists_to_process = [] + processed_artist_ids = set() + stopped_early = False + + logger.info("Checking artists from recent albums (with early stopping)...") + + for i, album in enumerate(recent_albums): + if self.should_stop: + break + + try: + # Get the artist for this album + album_artist = album.artist() + if not album_artist: + continue + + artist_id = int(album_artist.ratingKey) + + # Skip if we've already checked this artist + if artist_id in processed_artist_ids: + continue + + processed_artist_ids.add(artist_id) + + # Check if this artist is already current in our database + if self._artist_is_already_current(album_artist): + logger.info(f"Hit already-current artist '{album_artist.title}' at position {i+1} - stopping early!") + stopped_early = True + break + + # Artist needs processing + artists_to_process.append(album_artist) + logger.debug(f"Added artist '{album_artist.title}' for processing") + + except Exception as e: + logger.warning(f"Error checking album artist: {e}") + continue + + result_msg = f"Smart incremental scan result: {len(artists_to_process)} artists to process" + if stopped_early: + result_msg += f" (stopped early after checking {len(processed_artist_ids)} artists)" + else: + result_msg += f" (checked all {len(processed_artist_ids)} artists from recent albums)" + + logger.info(result_msg) + return artists_to_process + + except Exception as e: + logger.error(f"Error in smart incremental update: {e}") + # Fallback to empty list - user can try full refresh + return [] + + def _artist_is_already_current(self, plex_artist) -> bool: + """Check if an artist is already current in our database""" + try: + artist_id = int(plex_artist.ratingKey) + + # Get artist from database + db_artist = self.database.get_artist(artist_id) + + if not db_artist: + # Not in database at all + return False + + # Check if artist was updated recently (within last 24 hours) + if db_artist.updated_at: + from datetime import datetime, timedelta + hours_since_update = (datetime.now() - db_artist.updated_at).total_seconds() / 3600 + + # Consider "current" if updated within last 24 hours + if hours_since_update < 24: + logger.debug(f"Artist '{plex_artist.title}' is current (updated {hours_since_update:.1f} hours ago)") + return True + + # Artist exists but hasn't been updated recently + logger.debug(f"Artist '{plex_artist.title}' exists but needs refresh") + return False + + except Exception as e: + logger.warning(f"Error checking if artist is current: {e}") + # When in doubt, process the artist + return False + + def _process_all_artists(self, artists: List): + """Process all artists and their albums/tracks using thread pool""" + total_artists = len(artists) + + def process_single_artist(artist): + """Process a single artist and return results""" + if self.should_stop: + return None + + try: + artist_name = getattr(artist, 'title', 'Unknown Artist') + + # Update progress + with self.thread_lock: + self.processed_artists += 1 + progress_percent = (self.processed_artists / total_artists) * 100 + + self.progress_updated.emit( + f"Processing {artist_name}", + self.processed_artists, + total_artists, + progress_percent + ) + + # Process the artist + success, details, album_count, track_count = self._process_artist_with_content(artist) + + # Track statistics + with self.thread_lock: + if success: + self.successful_operations += 1 + else: + self.failed_operations += 1 + + self.processed_albums += album_count + self.processed_tracks += track_count + + return (artist_name, success, details, album_count, track_count) + + except Exception as e: + logger.error(f"Error processing artist {getattr(artist, 'title', 'Unknown')}: {e}") + return (getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0) + + # Process artists in parallel using ThreadPoolExecutor + 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 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, album_count, track_count = result + + # Emit progress signal + self.artist_processed.emit(artist_name, success, details, album_count, track_count) + + def _process_artist_with_content(self, plex_artist) -> tuple[bool, str, int, int]: + """Process an artist and all their albums and tracks""" + try: + artist_name = getattr(plex_artist, 'title', 'Unknown Artist') + + # 1. Insert/update the artist + artist_success = self.database.insert_or_update_artist(plex_artist) + if not artist_success: + return False, "Failed to update artist data", 0, 0 + + artist_id = int(plex_artist.ratingKey) + + # 2. Get all albums for this artist + try: + albums = list(plex_artist.albums()) + except Exception as e: + logger.warning(f"Could not get albums for artist '{artist_name}': {e}") + return True, "Artist updated (no albums accessible)", 0, 0 + + album_count = 0 + track_count = 0 + + # 3. Process each album + for album in albums: + if self.should_stop: + break + + try: + # Insert/update album + album_success = self.database.insert_or_update_album(album, artist_id) + if album_success: + album_count += 1 + album_id = int(album.ratingKey) + + # 4. Process tracks in this album + try: + tracks = list(album.tracks()) + + for track in tracks: + if self.should_stop: + break + + try: + track_success = self.database.insert_or_update_track(track, album_id, artist_id) + if track_success: + track_count += 1 + except Exception as e: + logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}") + + except Exception as e: + logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}") + + except Exception as e: + logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}") + + details = f"Updated with {album_count} albums, {track_count} tracks" + return True, details, album_count, track_count + + except Exception as e: + logger.error(f"Error processing artist '{getattr(plex_artist, 'title', 'Unknown')}': {e}") + return False, f"Processing error: {str(e)}", 0, 0 + +class DatabaseStatsWorker(QThread): + """Simple worker for getting database statistics without blocking UI""" + + stats_updated = pyqtSignal(dict) # Database statistics + + def __init__(self, database_path: str = "database/music_library.db"): + super().__init__() + self.database_path = database_path + self.should_stop = False + + def stop(self): + """Stop the worker""" + self.should_stop = True + + def run(self): + """Get database statistics""" + try: + if self.should_stop: + return + + database = get_database(self.database_path) + if self.should_stop: + return + + stats = database.get_database_info() + if not self.should_stop: + self.stats_updated.emit(stats) + except Exception as e: + logger.error(f"Error getting database stats: {e}") + if not self.should_stop: + self.stats_updated.emit({ + 'artists': 0, + 'albums': 0, + 'tracks': 0, + 'database_size_mb': 0.0, + 'last_update': None + }) \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 00000000..bd711f82 --- /dev/null +++ b/database/__init__.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +""" +SoulSync Database Module + +This module provides database functionality for storing and managing +music library metadata from Plex. It includes: + +- SQLite database management for artists, albums, and tracks +- Singleton database access pattern +- Data models for database entities +- Search and query capabilities + +Usage: + from database import get_database + + db = get_database() + stats = db.get_statistics() +""" + +from .music_database import ( + MusicDatabase, + DatabaseArtist, + DatabaseAlbum, + DatabaseTrack, + get_database, + close_database +) + +__all__ = [ + 'MusicDatabase', + 'DatabaseArtist', + 'DatabaseAlbum', + 'DatabaseTrack', + 'get_database', + 'close_database' +] + +__version__ = '1.0.0' \ No newline at end of file diff --git a/database/__pycache__/__init__.cpython-310.pyc b/database/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..b4eef0fe Binary files /dev/null and b/database/__pycache__/__init__.cpython-310.pyc differ diff --git a/database/__pycache__/__init__.cpython-312.pyc b/database/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..3e661224 Binary files /dev/null and b/database/__pycache__/__init__.cpython-312.pyc differ diff --git a/database/__pycache__/music_database.cpython-310.pyc b/database/__pycache__/music_database.cpython-310.pyc new file mode 100644 index 00000000..f4fdcf3a Binary files /dev/null and b/database/__pycache__/music_database.cpython-310.pyc differ diff --git a/database/__pycache__/music_database.cpython-312.pyc b/database/__pycache__/music_database.cpython-312.pyc new file mode 100644 index 00000000..82c3f9b7 Binary files /dev/null and b/database/__pycache__/music_database.cpython-312.pyc differ diff --git a/database/music_database.py b/database/music_database.py new file mode 100644 index 00000000..58ffa6c0 --- /dev/null +++ b/database/music_database.py @@ -0,0 +1,522 @@ +#!/usr/bin/env python3 + +import sqlite3 +import json +import os +import threading +from datetime import datetime +from typing import List, Optional, Dict, Any, Tuple +from dataclasses import dataclass +from pathlib import Path +from utils.logging_config import get_logger + +logger = get_logger("music_database") + +@dataclass +class DatabaseArtist: + id: int + name: str + thumb_url: Optional[str] = None + genres: Optional[List[str]] = None + summary: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + +@dataclass +class DatabaseAlbum: + id: int + artist_id: int + title: str + year: Optional[int] = None + thumb_url: Optional[str] = None + genres: Optional[List[str]] = None + track_count: Optional[int] = None + duration: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + +@dataclass +class DatabaseTrack: + id: int + album_id: int + artist_id: int + title: str + track_number: Optional[int] = None + duration: Optional[int] = None + file_path: Optional[str] = None + bitrate: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + +class MusicDatabase: + """SQLite database manager for SoulSync music library data""" + + def __init__(self, database_path: str = "database/music_library.db"): + self.database_path = Path(database_path) + self.database_path.parent.mkdir(parents=True, exist_ok=True) + self._connection: Optional[sqlite3.Connection] = None + + # Initialize database + self._initialize_database() + + def _get_connection(self) -> sqlite3.Connection: + """Get database connection with row factory""" + if self._connection is None: + self._connection = sqlite3.connect(str(self.database_path)) + self._connection.row_factory = sqlite3.Row + # Enable foreign key constraints + self._connection.execute("PRAGMA foreign_keys = ON") + return self._connection + + def _initialize_database(self): + """Create database tables if they don't exist""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Artists table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS artists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + thumb_url TEXT, + genres TEXT, -- JSON array + summary TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Albums table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS albums ( + id INTEGER PRIMARY KEY, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL, + year INTEGER, + thumb_url TEXT, + genres TEXT, -- JSON array + track_count INTEGER, + duration INTEGER, -- milliseconds + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (artist_id) REFERENCES artists (id) ON DELETE CASCADE + ) + """) + + # Tracks table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY, + album_id INTEGER NOT NULL, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL, + track_number INTEGER, + duration INTEGER, -- milliseconds + file_path TEXT, + bitrate INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE, + FOREIGN KEY (artist_id) REFERENCES artists (id) ON DELETE CASCADE + ) + """) + + # Create indexes for performance + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)") + + conn.commit() + logger.info("Database initialized successfully") + + except Exception as e: + logger.error(f"Error initializing database: {e}") + raise + + def close(self): + """Close database connection""" + if self._connection: + self._connection.close() + self._connection = None + + def get_statistics(self) -> Dict[str, int]: + """Get database statistics""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT COUNT(*) FROM artists") + artist_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM albums") + album_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM tracks") + track_count = cursor.fetchone()[0] + + return { + 'artists': artist_count, + 'albums': album_count, + 'tracks': track_count + } + except Exception as e: + logger.error(f"Error getting database statistics: {e}") + return {'artists': 0, 'albums': 0, 'tracks': 0} + + def clear_all_data(self): + """Clear all data from database (for full refresh)""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("DELETE FROM tracks") + cursor.execute("DELETE FROM albums") + cursor.execute("DELETE FROM artists") + + conn.commit() + logger.info("All database data cleared") + + except Exception as e: + logger.error(f"Error clearing database: {e}") + raise + + # Artist operations + def insert_or_update_artist(self, plex_artist) -> bool: + """Insert or update artist from Plex artist object""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + artist_id = int(plex_artist.ratingKey) + name = plex_artist.title + thumb_url = getattr(plex_artist, 'thumb', None) + summary = getattr(plex_artist, 'summary', None) + + # Get genres + genres = [] + if hasattr(plex_artist, 'genres') and plex_artist.genres: + genres = [genre.tag if hasattr(genre, 'tag') else str(genre) + for genre in plex_artist.genres] + + genres_json = json.dumps(genres) if genres else None + + # Check if artist exists + cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,)) + exists = cursor.fetchone() + + if exists: + # Update existing artist + cursor.execute(""" + UPDATE artists + SET name = ?, thumb_url = ?, genres = ?, summary = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (name, thumb_url, genres_json, summary, artist_id)) + else: + # Insert new artist + cursor.execute(""" + INSERT INTO artists (id, name, thumb_url, genres, summary) + VALUES (?, ?, ?, ?, ?) + """, (artist_id, name, thumb_url, genres_json, summary)) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error inserting/updating artist {getattr(plex_artist, 'title', 'Unknown')}: {e}") + return False + + def get_artist(self, artist_id: int) -> Optional[DatabaseArtist]: + """Get artist by ID""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM artists WHERE id = ?", (artist_id,)) + row = cursor.fetchone() + + if row: + genres = json.loads(row['genres']) if row['genres'] else None + return DatabaseArtist( + id=row['id'], + name=row['name'], + thumb_url=row['thumb_url'], + genres=genres, + summary=row['summary'], + created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None, + updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None + ) + return None + + except Exception as e: + logger.error(f"Error getting artist {artist_id}: {e}") + return None + + # Album operations + def insert_or_update_album(self, plex_album, artist_id: int) -> bool: + """Insert or update album from Plex album object""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + album_id = int(plex_album.ratingKey) + title = plex_album.title + year = getattr(plex_album, 'year', None) + thumb_url = getattr(plex_album, 'thumb', None) + + # Get track count and duration + track_count = getattr(plex_album, 'leafCount', None) + duration = getattr(plex_album, 'duration', None) + + # Get genres + genres = [] + if hasattr(plex_album, 'genres') and plex_album.genres: + genres = [genre.tag if hasattr(genre, 'tag') else str(genre) + for genre in plex_album.genres] + + genres_json = json.dumps(genres) if genres else None + + # Check if album exists + cursor.execute("SELECT id FROM albums WHERE id = ?", (album_id,)) + exists = cursor.fetchone() + + if exists: + # Update existing album + cursor.execute(""" + UPDATE albums + SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?, + track_count = ?, duration = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id)) + else: + # Insert new album + cursor.execute(""" + INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration)) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error inserting/updating album {getattr(plex_album, 'title', 'Unknown')}: {e}") + return False + + def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]: + """Get all albums by artist ID""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM albums WHERE artist_id = ? ORDER BY year, title", (artist_id,)) + rows = cursor.fetchall() + + albums = [] + for row in rows: + genres = json.loads(row['genres']) if row['genres'] else None + albums.append(DatabaseAlbum( + id=row['id'], + artist_id=row['artist_id'], + title=row['title'], + year=row['year'], + thumb_url=row['thumb_url'], + genres=genres, + track_count=row['track_count'], + duration=row['duration'], + created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None, + updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None + )) + + return albums + + except Exception as e: + logger.error(f"Error getting albums for artist {artist_id}: {e}") + return [] + + # Track operations + def insert_or_update_track(self, plex_track, album_id: int, artist_id: int) -> bool: + """Insert or update track from Plex track object""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + track_id = int(plex_track.ratingKey) + title = plex_track.title + track_number = getattr(plex_track, 'trackNumber', None) + duration = getattr(plex_track, 'duration', None) + + # Get file path and media info + file_path = None + bitrate = None + if hasattr(plex_track, 'media') and plex_track.media: + media = plex_track.media[0] if plex_track.media else None + if media: + if hasattr(media, 'parts') and media.parts: + part = media.parts[0] + file_path = getattr(part, 'file', None) + bitrate = getattr(media, 'bitrate', None) + + # Check if track exists + cursor.execute("SELECT id FROM tracks WHERE id = ?", (track_id,)) + exists = cursor.fetchone() + + if exists: + # Update existing track + cursor.execute(""" + UPDATE tracks + SET album_id = ?, artist_id = ?, title = ?, track_number = ?, + duration = ?, file_path = ?, bitrate = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (album_id, artist_id, title, track_number, duration, file_path, bitrate, track_id)) + else: + # Insert new track + cursor.execute(""" + INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate)) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error inserting/updating track {getattr(plex_track, 'title', 'Unknown')}: {e}") + return False + + def get_tracks_by_album(self, album_id: int) -> List[DatabaseTrack]: + """Get all tracks by album ID""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number, title", (album_id,)) + rows = cursor.fetchall() + + tracks = [] + for row in rows: + tracks.append(DatabaseTrack( + id=row['id'], + album_id=row['album_id'], + artist_id=row['artist_id'], + title=row['title'], + track_number=row['track_number'], + duration=row['duration'], + file_path=row['file_path'], + bitrate=row['bitrate'], + created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None, + updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None + )) + + return tracks + + except Exception as e: + logger.error(f"Error getting tracks for album {album_id}: {e}") + return [] + + def search_artists(self, query: str, limit: int = 50) -> List[DatabaseArtist]: + """Search artists by name""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM artists + WHERE name LIKE ? + ORDER BY name + LIMIT ? + """, (f"%{query}%", limit)) + + rows = cursor.fetchall() + + artists = [] + for row in rows: + genres = json.loads(row['genres']) if row['genres'] else None + artists.append(DatabaseArtist( + id=row['id'], + name=row['name'], + thumb_url=row['thumb_url'], + genres=genres, + summary=row['summary'], + created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None, + updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None + )) + + return artists + + except Exception as e: + logger.error(f"Error searching artists with query '{query}': {e}") + return [] + + def get_database_info(self) -> Dict[str, Any]: + """Get comprehensive database information""" + try: + stats = self.get_statistics() + + # Get database file size + db_size = self.database_path.stat().st_size if self.database_path.exists() else 0 + db_size_mb = db_size / (1024 * 1024) + + # Get last update time (most recent updated_at timestamp) + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT MAX(updated_at) as last_update + FROM ( + SELECT updated_at FROM artists + UNION ALL + SELECT updated_at FROM albums + UNION ALL + SELECT updated_at FROM tracks + ) + """) + + result = cursor.fetchone() + last_update = result['last_update'] if result and result['last_update'] else None + + return { + **stats, + 'database_size_mb': round(db_size_mb, 2), + 'database_path': str(self.database_path), + 'last_update': last_update + } + + except Exception as e: + logger.error(f"Error getting database info: {e}") + return { + 'artists': 0, + 'albums': 0, + 'tracks': 0, + 'database_size_mb': 0.0, + 'database_path': str(self.database_path), + 'last_update': None + } + +# Thread-safe singleton pattern for database access +_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance +_database_lock = threading.Lock() + +def get_database(database_path: str = "database/music_library.db") -> MusicDatabase: + """Get thread-local database instance""" + thread_id = threading.get_ident() + + with _database_lock: + if thread_id not in _database_instances: + _database_instances[thread_id] = MusicDatabase(database_path) + return _database_instances[thread_id] + +def close_database(): + """Close database instances (safe to call from any thread)""" + global _database_instances + + with _database_lock: + # Close all database instances + for thread_id, db_instance in list(_database_instances.items()): + try: + db_instance.close() + except Exception as e: + # Ignore threading errors during shutdown + pass + _database_instances.clear() \ No newline at end of file diff --git a/database/music_library.db b/database/music_library.db new file mode 100644 index 00000000..fc442bc7 Binary files /dev/null and b/database/music_library.db differ diff --git a/logs/app.log b/logs/app.log index 16298d55..d79cc6dd 100644 --- a/logs/app.log +++ b/logs/app.log @@ -8846,3 +8846,78 @@ 2025-08-05 15:51:19 - newmusic.main - INFO - closeEvent:321 - Stopping search maintenance timer... 2025-08-05 15:51:19 - newmusic.main - INFO - closeEvent:326 - Closing Soulseek client... 2025-08-05 15:51:19 - newmusic.main - INFO - closeEvent:332 - Application closed successfully +2025-08-05 22:16:07 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-08-05 22:16:07 - newmusic.main - INFO - main:346 - Starting Soulsync application +2025-08-05 22:16:07 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:16:07 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:16:07 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:16:08 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:16:08 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:16:08 - newmusic.main - INFO - change_page:289 - Changed to page: dashboard +2025-08-05 22:16:08 - newmusic.main - INFO - setup_media_player_connections:241 - Media player connections established between sidebar and downloads page +2025-08-05 22:16:08 - newmusic.main - INFO - setup_settings_connections:246 - Settings change connections established +2025-08-05 22:16:08 - newmusic.main - INFO - setup_search_maintenance:92 - Search maintenance timer started (every 2 minutes, keeps 200 most recent searches) +2025-08-05 22:16:08 - newmusic.plex_client - INFO - _find_music_library:129 - Found music library: Music +2025-08-05 22:16:08 - newmusic.plex_client - INFO - _setup_client:115 - Successfully connected to Plex server: PLEX-MACHINE +2025-08-05 22:18:08 - newmusic.soulseek_client - INFO - get_all_searches:949 - Retrieved 202 searches from slskd +2025-08-05 22:18:08 - newmusic.soulseek_client - INFO - maintain_search_history:1057 - Maintaining search history: deleting 2 oldest searches (keeping 200) +2025-08-05 22:18:08 - newmusic.soulseek_client - INFO - maintain_search_history:1075 - Search maintenance complete: 2 deleted, 0 failed +2025-08-05 22:20:08 - newmusic.soulseek_client - INFO - get_all_searches:949 - Retrieved 200 searches from slskd +2025-08-05 22:22:08 - newmusic.soulseek_client - INFO - get_all_searches:949 - Retrieved 200 searches from slskd +2025-08-05 22:24:08 - newmusic.soulseek_client - INFO - get_all_searches:949 - Retrieved 200 searches from slskd +2025-08-05 22:26:08 - newmusic.soulseek_client - INFO - get_all_searches:949 - Retrieved 200 searches from slskd +2025-08-05 22:26:51 - newmusic.main - INFO - closeEvent:306 - Closing application... +2025-08-05 22:26:51 - newmusic.main - INFO - closeEvent:311 - Cleaning up Downloads page threads... +2025-08-05 22:26:51 - newmusic.main - INFO - closeEvent:316 - Stopping status monitoring thread... +2025-08-05 22:26:58 - newmusic.main - INFO - closeEvent:321 - Stopping search maintenance timer... +2025-08-05 22:26:58 - newmusic.main - INFO - closeEvent:326 - Closing Soulseek client... +2025-08-05 22:26:58 - newmusic.main - INFO - closeEvent:332 - Application closed successfully +2025-08-05 22:35:21 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-08-05 22:35:21 - newmusic.main - INFO - main:346 - Starting Soulsync application +2025-08-05 22:35:21 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:35:21 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:35:36 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-08-05 22:35:36 - newmusic.main - INFO - main:346 - Starting Soulsync application +2025-08-05 22:35:36 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:35:36 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:36:15 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-08-05 22:36:15 - newmusic.main - INFO - main:359 - Starting Soulsync application +2025-08-05 22:36:15 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:36:15 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:36:59 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-08-05 22:36:59 - newmusic.main - INFO - main:359 - Starting Soulsync application +2025-08-05 22:36:59 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:36:59 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:37:00 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:37:00 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:37:00 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:37:00 - newmusic.main - INFO - change_page:289 - Changed to page: dashboard +2025-08-05 22:37:00 - newmusic.main - INFO - setup_media_player_connections:241 - Media player connections established between sidebar and downloads page +2025-08-05 22:37:00 - newmusic.main - INFO - setup_settings_connections:246 - Settings change connections established +2025-08-05 22:37:00 - newmusic.main - INFO - setup_search_maintenance:92 - Search maintenance timer started (every 2 minutes, keeps 200 most recent searches) +2025-08-05 22:37:00 - newmusic.plex_client - INFO - _find_music_library:129 - Found music library: Music +2025-08-05 22:37:00 - newmusic.plex_client - INFO - _setup_client:115 - Successfully connected to Plex server: PLEX-MACHINE +2025-08-05 22:37:59 - newmusic - INFO - setup_logging:57 - Logging initialized with level: INFO +2025-08-05 22:37:59 - newmusic.main - INFO - main:359 - Starting Soulsync application +2025-08-05 22:37:59 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:37:59 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:37:59 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:38:00 - newmusic.spotify_client - INFO - _setup_client:179 - Spotify client initialized (user info will be fetched when needed) +2025-08-05 22:38:00 - newmusic.soulseek_client - INFO - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030 +2025-08-05 22:38:00 - newmusic.main - INFO - change_page:289 - Changed to page: dashboard +2025-08-05 22:38:00 - newmusic.main - INFO - setup_media_player_connections:241 - Media player connections established between sidebar and downloads page +2025-08-05 22:38:00 - newmusic.main - INFO - setup_settings_connections:246 - Settings change connections established +2025-08-05 22:38:00 - newmusic.main - INFO - setup_search_maintenance:92 - Search maintenance timer started (every 2 minutes, keeps 200 most recent searches) +2025-08-05 22:38:00 - newmusic.plex_client - INFO - _find_music_library:129 - Found music library: Music +2025-08-05 22:38:00 - newmusic.plex_client - INFO - _setup_client:115 - Successfully connected to Plex server: PLEX-MACHINE +2025-08-05 22:38:00 - newmusic.music_database - INFO - _initialize_database:133 - Database initialized successfully +2025-08-05 22:40:00 - newmusic.soulseek_client - INFO - get_all_searches:949 - Retrieved 200 searches from slskd +2025-08-05 22:41:09 - newmusic.main - INFO - closeEvent:306 - Closing application... +2025-08-05 22:41:09 - newmusic.main - INFO - closeEvent:311 - Cleaning up Downloads page threads... +2025-08-05 22:41:09 - newmusic.main - INFO - closeEvent:316 - Cleaning up Dashboard page threads... +2025-08-05 22:41:09 - newmusic.main - INFO - closeEvent:321 - Stopping status monitoring thread... +2025-08-05 22:41:10 - newmusic.main - INFO - closeEvent:326 - Stopping search maintenance timer... +2025-08-05 22:41:10 - newmusic.main - INFO - closeEvent:331 - Closing Soulseek client... +2025-08-05 22:41:10 - newmusic.main - INFO - closeEvent:339 - Closing database connection... +2025-08-05 22:41:10 - newmusic.main - ERROR - closeEvent:343 - Error closing database: SQLite objects created in a thread can only be used in that same thread. The object was created in thread id 24524 and this is thread id 92856. +2025-08-05 22:41:10 - newmusic.main - INFO - closeEvent:345 - Application closed successfully diff --git a/main.py b/main.py index 0ad9020e..976dba1e 100644 --- a/main.py +++ b/main.py @@ -311,6 +311,11 @@ class MainWindow(QMainWindow): logger.info("Cleaning up Downloads page threads...") self.downloads_page.cleanup_all_threads() + # Stop dashboard threads + if hasattr(self, 'dashboard_page') and self.dashboard_page: + logger.info("Cleaning up Dashboard page threads...") + self.dashboard_page.cleanup_threads() + # Stop status monitoring thread if self.status_thread: logger.info("Stopping status monitoring thread...") @@ -329,6 +334,14 @@ class MainWindow(QMainWindow): except Exception as e: logger.error(f"Error closing Soulseek client: {e}") + # Close database connection + try: + logger.info("Closing database connection...") + from database import close_database + close_database() + except Exception as e: + logger.error(f"Error closing database: {e}") + logger.info("Application closed successfully") event.accept() diff --git a/ui/components/__pycache__/database_updater_widget.cpython-312.pyc b/ui/components/__pycache__/database_updater_widget.cpython-312.pyc new file mode 100644 index 00000000..11560c50 Binary files /dev/null and b/ui/components/__pycache__/database_updater_widget.cpython-312.pyc differ diff --git a/ui/components/database_updater_widget.py b/ui/components/database_updater_widget.py new file mode 100644 index 00000000..ab2238be --- /dev/null +++ b/ui/components/database_updater_widget.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 + +from PyQt6.QtWidgets import (QFrame, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QProgressBar, QComboBox, QGroupBox) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +class DatabaseUpdaterWidget(QFrame): + """UI widget for updating SoulSync database with Plex library data""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + self.setStyleSheet(""" + DatabaseUpdaterWidget { + 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("Update SoulSync Database") + header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + header_label.setStyleSheet("color: #ffffff;") + + # Info label + info_label = QLabel("Syncs your Plex music library into the local database for faster searches and analytics") + info_label.setFont(QFont("Arial", 9)) + info_label.setStyleSheet("color: #b3b3b3; margin-bottom: 5px;") + info_label.setWordWrap(True) + + # Control section + control_layout = QVBoxLayout() + control_layout.setSpacing(12) + + # Top row: Button + button_layout = QHBoxLayout() + self.start_button = QPushButton("Update Database") + 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) + + # Update type dropdown + update_type_layout = QVBoxLayout() + update_type_layout.setSpacing(4) + + type_label = QLabel("Update Type:") + type_label.setFont(QFont("Arial", 9)) + type_label.setStyleSheet("color: #b3b3b3;") + + self.update_type_combo = QComboBox() + self.update_type_combo.setFixedHeight(32) + self.update_type_combo.setFont(QFont("Arial", 10)) + self.update_type_combo.addItems([ + "Incremental Update", + "Full Refresh" + ]) + self.update_type_combo.setCurrentText("Incremental Update") + self.update_type_combo.setStyleSheet(""" + QComboBox { + background: #333333; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + padding: 4px 8px; + min-width: 140px; + } + 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; + } + """) + + update_type_layout.addWidget(type_label) + update_type_layout.addWidget(self.update_type_combo) + + # Current status display + status_layout = QVBoxLayout() + status_layout.setSpacing(4) + + current_label = QLabel("Current Status:") + current_label.setFont(QFont("Arial", 9)) + current_label.setStyleSheet("color: #b3b3b3;") + + self.current_status_label = QLabel("Ready") + self.current_status_label.setFont(QFont("Arial", 11, QFont.Weight.Medium)) + self.current_status_label.setStyleSheet("color: #ffffff;") + + status_layout.addWidget(current_label) + status_layout.addWidget(self.current_status_label) + + settings_layout.addLayout(update_type_layout) + settings_layout.addLayout(status_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 artists processed") + 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) + + # Statistics section (shows current database info) + stats_group = QGroupBox("Database Statistics") + stats_group.setFont(QFont("Arial", 10, QFont.Weight.Bold)) + stats_group.setStyleSheet(""" + QGroupBox { + color: #ffffff; + border: 1px solid #555555; + border-radius: 6px; + margin-top: 6px; + padding-top: 10px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + } + """) + + stats_layout = QHBoxLayout(stats_group) + stats_layout.setSpacing(20) + + # Artists stat + artists_layout = QVBoxLayout() + self.artists_count_label = QLabel("0") + self.artists_count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + self.artists_count_label.setStyleSheet("color: #1db954;") + self.artists_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + artists_text_label = QLabel("Artists") + artists_text_label.setFont(QFont("Arial", 9)) + artists_text_label.setStyleSheet("color: #b3b3b3;") + artists_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + artists_layout.addWidget(self.artists_count_label) + artists_layout.addWidget(artists_text_label) + + # Albums stat + albums_layout = QVBoxLayout() + self.albums_count_label = QLabel("0") + self.albums_count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + self.albums_count_label.setStyleSheet("color: #1db954;") + self.albums_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + albums_text_label = QLabel("Albums") + albums_text_label.setFont(QFont("Arial", 9)) + albums_text_label.setStyleSheet("color: #b3b3b3;") + albums_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + albums_layout.addWidget(self.albums_count_label) + albums_layout.addWidget(albums_text_label) + + # Tracks stat + tracks_layout = QVBoxLayout() + self.tracks_count_label = QLabel("0") + self.tracks_count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + self.tracks_count_label.setStyleSheet("color: #1db954;") + self.tracks_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + tracks_text_label = QLabel("Tracks") + tracks_text_label.setFont(QFont("Arial", 9)) + tracks_text_label.setStyleSheet("color: #b3b3b3;") + tracks_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + tracks_layout.addWidget(self.tracks_count_label) + tracks_layout.addWidget(tracks_text_label) + + # Database size stat + size_layout = QVBoxLayout() + self.size_label = QLabel("0.0 MB") + self.size_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + self.size_label.setStyleSheet("color: #1db954;") + self.size_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + size_text_label = QLabel("DB Size") + size_text_label.setFont(QFont("Arial", 9)) + size_text_label.setStyleSheet("color: #b3b3b3;") + size_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + size_layout.addWidget(self.size_label) + size_layout.addWidget(size_text_label) + + stats_layout.addLayout(artists_layout) + stats_layout.addLayout(albums_layout) + stats_layout.addLayout(tracks_layout) + stats_layout.addLayout(size_layout) + stats_layout.addStretch() + + # Add all sections to main layout + layout.addWidget(header_label) + layout.addWidget(info_label) + layout.addLayout(control_layout) + layout.addLayout(progress_layout) + layout.addWidget(stats_group) + + def update_progress(self, is_running: bool, current_item: str, processed: int, total: int, percentage: float): + """Update progress display during database update""" + if is_running: + self.start_button.setText("Stop Update") + self.start_button.setEnabled(True) + self.current_status_label.setText(current_item if current_item else "Processing...") + self.progress_label.setText(f"Progress: {percentage:.1f}%") + self.count_label.setText(f"{processed} / {total} artists processed") + self.progress_bar.setValue(int(percentage)) + else: + self.start_button.setText("Update Database") + self.start_button.setEnabled(True) + self.current_status_label.setText("Ready") + self.progress_label.setText("Progress: 0%") + self.count_label.setText("0 artists processed") + self.progress_bar.setValue(0) + + def update_statistics(self, stats: dict): + """Update database statistics display""" + self.artists_count_label.setText(str(stats.get('artists', 0))) + self.albums_count_label.setText(str(stats.get('albums', 0))) + self.tracks_count_label.setText(str(stats.get('tracks', 0))) + self.size_label.setText(f"{stats.get('database_size_mb', 0.0):.1f} MB") + + def update_phase(self, phase: str): + """Update current phase display""" + self.current_status_label.setText(phase) + + def is_full_refresh(self) -> bool: + """Check if full refresh is selected""" + return self.update_type_combo.currentText() == "Full Refresh" + + def set_button_text(self, text: str): + """Set custom button text""" + self.start_button.setText(text) + + def set_button_enabled(self, enabled: bool): + """Enable/disable the start button""" + self.start_button.setEnabled(enabled) \ No newline at end of file diff --git a/ui/pages/__pycache__/__init__.cpython-310.pyc b/ui/pages/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..7c603580 Binary files /dev/null and b/ui/pages/__pycache__/__init__.cpython-310.pyc differ diff --git a/ui/pages/__pycache__/dashboard.cpython-310.pyc b/ui/pages/__pycache__/dashboard.cpython-310.pyc new file mode 100644 index 00000000..f9ee8a63 Binary files /dev/null and b/ui/pages/__pycache__/dashboard.cpython-310.pyc differ diff --git a/ui/pages/__pycache__/dashboard.cpython-312.pyc b/ui/pages/__pycache__/dashboard.cpython-312.pyc index afdf5e12..88b705f0 100644 Binary files a/ui/pages/__pycache__/dashboard.cpython-312.pyc and b/ui/pages/__pycache__/dashboard.cpython-312.pyc differ diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index 98ae03ed..c278c4a9 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -20,6 +20,8 @@ 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 class MetadataUpdateWorker(QThread): """Worker thread for updating Plex artist metadata using Spotify data""" @@ -1205,6 +1207,12 @@ class DashboardPage(QWidget): 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""" @@ -1383,11 +1391,16 @@ class DashboardPage(QWidget): header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) header_label.setStyleSheet("color: #ffffff;") - # Metadata updater widget + # 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 @@ -1451,6 +1464,154 @@ class DashboardPage(QWidget): # 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.database_widget.update_statistics) + 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: + self.database_widget.update_statistics({ + 'artists': 0, + 'albums': 0, + 'tracks': 0, + 'database_size_mb': 0.0 + }) + + 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() @@ -1719,4 +1880,28 @@ class DashboardPage(QWidget): 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() \ No newline at end of file + 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() \ No newline at end of file diff --git a/utils/__pycache__/logging_config.cpython-310.pyc b/utils/__pycache__/logging_config.cpython-310.pyc new file mode 100644 index 00000000..c42ed872 Binary files /dev/null and b/utils/__pycache__/logging_config.cpython-310.pyc differ