basic db structure
This commit is contained in:
parent
df475692a9
commit
b76c0531e5
19 changed files with 1560 additions and 2 deletions
BIN
core/__pycache__/database_update_worker.cpython-310.pyc
Normal file
BIN
core/__pycache__/database_update_worker.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/database_update_worker.cpython-312.pyc
Normal file
BIN
core/__pycache__/database_update_worker.cpython-312.pyc
Normal file
Binary file not shown.
409
core/database_update_worker.py
Normal file
409
core/database_update_worker.py
Normal 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
39
database/__init__.py
Normal 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'
|
||||||
BIN
database/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
database/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
database/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
database/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
database/__pycache__/music_database.cpython-310.pyc
Normal file
BIN
database/__pycache__/music_database.cpython-310.pyc
Normal file
Binary file not shown.
BIN
database/__pycache__/music_database.cpython-312.pyc
Normal file
BIN
database/__pycache__/music_database.cpython-312.pyc
Normal file
Binary file not shown.
522
database/music_database.py
Normal file
522
database/music_database.py
Normal 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
BIN
database/music_library.db
Normal file
Binary file not shown.
75
logs/app.log
75
logs/app.log
|
|
@ -8846,3 +8846,78 @@
|
||||||
2025-08-05 15:51:19 - newmusic.main - [92mINFO[0m - closeEvent:321 - Stopping search maintenance timer...
|
2025-08-05 15:51:19 - newmusic.main - [92mINFO[0m - closeEvent:321 - Stopping search maintenance timer...
|
||||||
2025-08-05 15:51:19 - newmusic.main - [92mINFO[0m - closeEvent:326 - Closing Soulseek client...
|
2025-08-05 15:51:19 - newmusic.main - [92mINFO[0m - closeEvent:326 - Closing Soulseek client...
|
||||||
2025-08-05 15:51:19 - newmusic.main - [92mINFO[0m - closeEvent:332 - Application closed successfully
|
2025-08-05 15:51:19 - newmusic.main - [92mINFO[0m - closeEvent:332 - Application closed successfully
|
||||||
|
2025-08-05 22:16:07 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: INFO
|
||||||
|
2025-08-05 22:16:07 - newmusic.main - [92mINFO[0m - main:346 - Starting Soulsync application
|
||||||
|
2025-08-05 22:16:07 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:16:07 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:16:07 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:16:08 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:16:08 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:16:08 - newmusic.main - [92mINFO[0m - change_page:289 - Changed to page: dashboard
|
||||||
|
2025-08-05 22:16:08 - newmusic.main - [92mINFO[0m - setup_media_player_connections:241 - Media player connections established between sidebar and downloads page
|
||||||
|
2025-08-05 22:16:08 - newmusic.main - [92mINFO[0m - setup_settings_connections:246 - Settings change connections established
|
||||||
|
2025-08-05 22:16:08 - newmusic.main - [92mINFO[0m - 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 - [92mINFO[0m - _find_music_library:129 - Found music library: Music
|
||||||
|
2025-08-05 22:16:08 - newmusic.plex_client - [92mINFO[0m - _setup_client:115 - Successfully connected to Plex server: PLEX-MACHINE
|
||||||
|
2025-08-05 22:18:08 - newmusic.soulseek_client - [92mINFO[0m - get_all_searches:949 - Retrieved 202 searches from slskd
|
||||||
|
2025-08-05 22:18:08 - newmusic.soulseek_client - [92mINFO[0m - maintain_search_history:1057 - Maintaining search history: deleting 2 oldest searches (keeping 200)
|
||||||
|
2025-08-05 22:18:08 - newmusic.soulseek_client - [92mINFO[0m - maintain_search_history:1075 - Search maintenance complete: 2 deleted, 0 failed
|
||||||
|
2025-08-05 22:20:08 - newmusic.soulseek_client - [92mINFO[0m - get_all_searches:949 - Retrieved 200 searches from slskd
|
||||||
|
2025-08-05 22:22:08 - newmusic.soulseek_client - [92mINFO[0m - get_all_searches:949 - Retrieved 200 searches from slskd
|
||||||
|
2025-08-05 22:24:08 - newmusic.soulseek_client - [92mINFO[0m - get_all_searches:949 - Retrieved 200 searches from slskd
|
||||||
|
2025-08-05 22:26:08 - newmusic.soulseek_client - [92mINFO[0m - get_all_searches:949 - Retrieved 200 searches from slskd
|
||||||
|
2025-08-05 22:26:51 - newmusic.main - [92mINFO[0m - closeEvent:306 - Closing application...
|
||||||
|
2025-08-05 22:26:51 - newmusic.main - [92mINFO[0m - closeEvent:311 - Cleaning up Downloads page threads...
|
||||||
|
2025-08-05 22:26:51 - newmusic.main - [92mINFO[0m - closeEvent:316 - Stopping status monitoring thread...
|
||||||
|
2025-08-05 22:26:58 - newmusic.main - [92mINFO[0m - closeEvent:321 - Stopping search maintenance timer...
|
||||||
|
2025-08-05 22:26:58 - newmusic.main - [92mINFO[0m - closeEvent:326 - Closing Soulseek client...
|
||||||
|
2025-08-05 22:26:58 - newmusic.main - [92mINFO[0m - closeEvent:332 - Application closed successfully
|
||||||
|
2025-08-05 22:35:21 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: INFO
|
||||||
|
2025-08-05 22:35:21 - newmusic.main - [92mINFO[0m - main:346 - Starting Soulsync application
|
||||||
|
2025-08-05 22:35:21 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:35:21 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:35:36 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: INFO
|
||||||
|
2025-08-05 22:35:36 - newmusic.main - [92mINFO[0m - main:346 - Starting Soulsync application
|
||||||
|
2025-08-05 22:35:36 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:35:36 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:36:15 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: INFO
|
||||||
|
2025-08-05 22:36:15 - newmusic.main - [92mINFO[0m - main:359 - Starting Soulsync application
|
||||||
|
2025-08-05 22:36:15 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:36:15 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:36:59 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: INFO
|
||||||
|
2025-08-05 22:36:59 - newmusic.main - [92mINFO[0m - main:359 - Starting Soulsync application
|
||||||
|
2025-08-05 22:36:59 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:36:59 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:37:00 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:37:00 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:37:00 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:37:00 - newmusic.main - [92mINFO[0m - change_page:289 - Changed to page: dashboard
|
||||||
|
2025-08-05 22:37:00 - newmusic.main - [92mINFO[0m - setup_media_player_connections:241 - Media player connections established between sidebar and downloads page
|
||||||
|
2025-08-05 22:37:00 - newmusic.main - [92mINFO[0m - setup_settings_connections:246 - Settings change connections established
|
||||||
|
2025-08-05 22:37:00 - newmusic.main - [92mINFO[0m - 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 - [92mINFO[0m - _find_music_library:129 - Found music library: Music
|
||||||
|
2025-08-05 22:37:00 - newmusic.plex_client - [92mINFO[0m - _setup_client:115 - Successfully connected to Plex server: PLEX-MACHINE
|
||||||
|
2025-08-05 22:37:59 - newmusic - [92mINFO[0m - setup_logging:57 - Logging initialized with level: INFO
|
||||||
|
2025-08-05 22:37:59 - newmusic.main - [92mINFO[0m - main:359 - Starting Soulsync application
|
||||||
|
2025-08-05 22:37:59 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:37:59 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:37:59 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:38:00 - newmusic.spotify_client - [92mINFO[0m - _setup_client:179 - Spotify client initialized (user info will be fetched when needed)
|
||||||
|
2025-08-05 22:38:00 - newmusic.soulseek_client - [92mINFO[0m - _setup_client:220 - Soulseek client configured with slskd at http://localhost:5030
|
||||||
|
2025-08-05 22:38:00 - newmusic.main - [92mINFO[0m - change_page:289 - Changed to page: dashboard
|
||||||
|
2025-08-05 22:38:00 - newmusic.main - [92mINFO[0m - setup_media_player_connections:241 - Media player connections established between sidebar and downloads page
|
||||||
|
2025-08-05 22:38:00 - newmusic.main - [92mINFO[0m - setup_settings_connections:246 - Settings change connections established
|
||||||
|
2025-08-05 22:38:00 - newmusic.main - [92mINFO[0m - 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 - [92mINFO[0m - _find_music_library:129 - Found music library: Music
|
||||||
|
2025-08-05 22:38:00 - newmusic.plex_client - [92mINFO[0m - _setup_client:115 - Successfully connected to Plex server: PLEX-MACHINE
|
||||||
|
2025-08-05 22:38:00 - newmusic.music_database - [92mINFO[0m - _initialize_database:133 - Database initialized successfully
|
||||||
|
2025-08-05 22:40:00 - newmusic.soulseek_client - [92mINFO[0m - get_all_searches:949 - Retrieved 200 searches from slskd
|
||||||
|
2025-08-05 22:41:09 - newmusic.main - [92mINFO[0m - closeEvent:306 - Closing application...
|
||||||
|
2025-08-05 22:41:09 - newmusic.main - [92mINFO[0m - closeEvent:311 - Cleaning up Downloads page threads...
|
||||||
|
2025-08-05 22:41:09 - newmusic.main - [92mINFO[0m - closeEvent:316 - Cleaning up Dashboard page threads...
|
||||||
|
2025-08-05 22:41:09 - newmusic.main - [92mINFO[0m - closeEvent:321 - Stopping status monitoring thread...
|
||||||
|
2025-08-05 22:41:10 - newmusic.main - [92mINFO[0m - closeEvent:326 - Stopping search maintenance timer...
|
||||||
|
2025-08-05 22:41:10 - newmusic.main - [92mINFO[0m - closeEvent:331 - Closing Soulseek client...
|
||||||
|
2025-08-05 22:41:10 - newmusic.main - [92mINFO[0m - closeEvent:339 - Closing database connection...
|
||||||
|
2025-08-05 22:41:10 - newmusic.main - [91mERROR[0m - 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 - [92mINFO[0m - closeEvent:345 - Application closed successfully
|
||||||
|
|
|
||||||
13
main.py
13
main.py
|
|
@ -311,6 +311,11 @@ class MainWindow(QMainWindow):
|
||||||
logger.info("Cleaning up Downloads page threads...")
|
logger.info("Cleaning up Downloads page threads...")
|
||||||
self.downloads_page.cleanup_all_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
|
# Stop status monitoring thread
|
||||||
if self.status_thread:
|
if self.status_thread:
|
||||||
logger.info("Stopping status monitoring thread...")
|
logger.info("Stopping status monitoring thread...")
|
||||||
|
|
@ -329,6 +334,14 @@ class MainWindow(QMainWindow):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error closing Soulseek client: {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")
|
logger.info("Application closed successfully")
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
315
ui/components/database_updater_widget.py
Normal file
315
ui/components/database_updater_widget.py
Normal 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)
|
||||||
BIN
ui/pages/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
ui/pages/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
ui/pages/__pycache__/dashboard.cpython-310.pyc
Normal file
BIN
ui/pages/__pycache__/dashboard.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -20,6 +20,8 @@ import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import io
|
import io
|
||||||
from core.matching_engine import MusicMatchingEngine
|
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):
|
class MetadataUpdateWorker(QThread):
|
||||||
"""Worker thread for updating Plex artist metadata using Spotify data"""
|
"""Worker thread for updating Plex artist metadata using Spotify data"""
|
||||||
|
|
@ -1206,6 +1208,12 @@ class DashboardPage(QWidget):
|
||||||
|
|
||||||
self.setup_ui()
|
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):
|
def set_service_clients(self, spotify_client, plex_client, soulseek_client):
|
||||||
"""Called from main window to provide service client references"""
|
"""Called from main window to provide service client references"""
|
||||||
self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client)
|
self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client)
|
||||||
|
|
@ -1383,11 +1391,16 @@ class DashboardPage(QWidget):
|
||||||
header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||||||
header_label.setStyleSheet("color: #ffffff;")
|
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 = MetadataUpdaterWidget()
|
||||||
self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update)
|
self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update)
|
||||||
|
|
||||||
layout.addWidget(header_label)
|
layout.addWidget(header_label)
|
||||||
|
layout.addWidget(self.database_widget)
|
||||||
layout.addWidget(self.metadata_widget)
|
layout.addWidget(self.metadata_widget)
|
||||||
|
|
||||||
return section
|
return section
|
||||||
|
|
@ -1451,6 +1464,154 @@ class DashboardPage(QWidget):
|
||||||
# Start test
|
# Start test
|
||||||
self.data_provider.test_service_connection(service)
|
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):
|
def toggle_metadata_update(self):
|
||||||
"""Toggle metadata update process"""
|
"""Toggle metadata update process"""
|
||||||
current_text = self.metadata_widget.start_button.text()
|
current_text = self.metadata_widget.start_button.text()
|
||||||
|
|
@ -1720,3 +1881,27 @@ class DashboardPage(QWidget):
|
||||||
self.data_provider.download_stats_timer.stop()
|
self.data_provider.download_stats_timer.stop()
|
||||||
if hasattr(self.data_provider, 'system_stats_timer'):
|
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()
|
||||||
BIN
utils/__pycache__/logging_config.cpython-310.pyc
Normal file
BIN
utils/__pycache__/logging_config.cpython-310.pyc
Normal file
Binary file not shown.
Loading…
Reference in a new issue