basic db structure

This commit is contained in:
Broque Thomas 2025-08-05 22:43:04 -07:00
parent df475692a9
commit b76c0531e5
19 changed files with 1560 additions and 2 deletions

View file

@ -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
})

39
database/__init__.py Normal file
View file

@ -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'

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

522
database/music_database.py Normal file
View file

@ -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()

BIN
database/music_library.db Normal file

Binary file not shown.

View file

@ -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

13
main.py
View file

@ -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()

View file

@ -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)

Binary file not shown.

Binary file not shown.

View file

@ -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()
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()

Binary file not shown.