From b76c0531e581156a690951f0eae95e0786abb60f Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Tue, 5 Aug 2025 22:43:04 -0700 Subject: [PATCH] basic db structure --- .../database_update_worker.cpython-310.pyc | Bin 0 -> 7249 bytes .../database_update_worker.cpython-312.pyc | Bin 0 -> 12228 bytes core/database_update_worker.py | 409 ++++++++++++++ database/__init__.py | 39 ++ database/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 799 bytes database/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 822 bytes .../music_database.cpython-310.pyc | Bin 0 -> 15246 bytes .../music_database.cpython-312.pyc | Bin 0 -> 23050 bytes database/music_database.py | 522 ++++++++++++++++++ database/music_library.db | Bin 0 -> 40960 bytes logs/app.log | 75 +++ main.py | 13 + .../database_updater_widget.cpython-312.pyc | Bin 0 -> 16539 bytes ui/components/database_updater_widget.py | 315 +++++++++++ ui/pages/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 145 bytes .../__pycache__/dashboard.cpython-310.pyc | Bin 0 -> 52479 bytes .../__pycache__/dashboard.cpython-312.pyc | Bin 85542 -> 96185 bytes ui/pages/dashboard.py | 189 ++++++- .../logging_config.cpython-310.pyc | Bin 0 -> 2004 bytes 19 files changed, 1560 insertions(+), 2 deletions(-) create mode 100644 core/__pycache__/database_update_worker.cpython-310.pyc create mode 100644 core/__pycache__/database_update_worker.cpython-312.pyc create mode 100644 core/database_update_worker.py create mode 100644 database/__init__.py create mode 100644 database/__pycache__/__init__.cpython-310.pyc create mode 100644 database/__pycache__/__init__.cpython-312.pyc create mode 100644 database/__pycache__/music_database.cpython-310.pyc create mode 100644 database/__pycache__/music_database.cpython-312.pyc create mode 100644 database/music_database.py create mode 100644 database/music_library.db create mode 100644 ui/components/__pycache__/database_updater_widget.cpython-312.pyc create mode 100644 ui/components/database_updater_widget.py create mode 100644 ui/pages/__pycache__/__init__.cpython-310.pyc create mode 100644 ui/pages/__pycache__/dashboard.cpython-310.pyc create mode 100644 utils/__pycache__/logging_config.cpython-310.pyc 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 0000000000000000000000000000000000000000..8281902c5f0befea09d220707c90c251845ead58 GIT binary patch literal 7249 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..79c01f31d3758d9852005ba710086c86d3bd9352 GIT binary patch literal 12228 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 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 0000000000000000000000000000000000000000..b4eef0febab78f0b0b70bdf6be184cff8ad2d371 GIT binary patch literal 799 zcmY*Xy^a$x5Oy~I%OwRp(v(Dk%KMlmyS~Utc%(x`g~p#_dy}@eH4Eh9F2qL`M|5V~VOU z-KuMPRnPRRz8O>lGpvRkvL>SV-l;~STa86;NoIZatwTt;bd6r_EQgmYvYG`67fv)< zmgUt(1u)rwt#>y{$N*v=wQ4Mn%2}pW+`-Cu2+?_E*TAd*!z^2)QJN+w4qDZo`5hP; zSz>^dcLpxC{5*$O5tQY+LH6^qdOdL`1fd1cjM`Si= zCl^V`)Oxz3cDnz1p5+?9H+FM z=4(5I__S>v*O2&Lf_{QQf+0dmV_IX{9ZGMc54aVS{viG&MI`uhwlFp>QC?{Nc r_d)U)7FK@Bl@@!k7L+QhBBk@~t{px8w|!5O24RHIE8y;EJR1K6tTp)t literal 0 HcmV?d00001 diff --git a/database/__pycache__/__init__.cpython-312.pyc b/database/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e6612249ba5fd18d6d503965c46f99b32eb0a37 GIT binary patch literal 822 zcmY*XJ&zMH5cO_8Hf%^ZT|tLrAqpZv6eOzXKmtJl;?SWS%_@@R*iO!1uY>J92o?MV z8d^lhZ{Z(oO9S1(6(?QA*pLKVJbOGdZ|05twb#oLtjFI!99?N3^eq_cmHs`P-iE_R z#8H8G!c&##v`BTMXy|6q)UBeW+eKSvMV6oi;_2t4=lSMd@P|YQ1#c z-N01MDjjwx=PhL~T}XzSN_bOl^C@G(IlQFa3tK`=5E^s|VpIe^!*~93T_41I=c#w#;)w7CWN1CSQQcpk?!|G`y^S5FvNaFGVCswi_EYznGEQvzd)vA- z91TZL2YHl%9~`a&_L>`}67U+8rPW`rTE4lSYyxKifM= c^Xyudq-O}=*KXEL-(@Ge=+fo)UE7DS-~VF)U;qFB literal 0 HcmV?d00001 diff --git a/database/__pycache__/music_database.cpython-310.pyc b/database/__pycache__/music_database.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4fdcf3a712b3091dcf3d8f20a20497e803c8fa5 GIT binary patch literal 15246 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 Q3&X2;S#`07deQ;^4>_Qrga7~l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fc442bc7b77c33b0bbc88df55a491bf3d902fabd GIT binary patch literal 40960 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7c603580d22d1e62238bdc254b2ce137933f57a8 GIT binary patch literal 145 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 delta 12645 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 h-%F4TID^Z(RTa3~`pC2T