diff --git a/core/__pycache__/database_update_worker.cpython-312.pyc b/core/__pycache__/database_update_worker.cpython-312.pyc index 79c01f31..b84fec2e 100644 Binary files a/core/__pycache__/database_update_worker.cpython-312.pyc and b/core/__pycache__/database_update_worker.cpython-312.pyc differ diff --git a/core/database_update_worker.py b/core/database_update_worker.py index 5c81ed25..29a86cb0 100644 --- a/core/database_update_worker.py +++ b/core/database_update_worker.py @@ -9,6 +9,7 @@ import time from database import get_database, MusicDatabase from utils.logging_config import get_logger +from config.settings import config_manager logger = get_logger("database_update_worker") @@ -36,8 +37,10 @@ class DatabaseUpdateWorker(QThread): self.successful_operations = 0 self.failed_operations = 0 - # Threading control - self.max_workers = 3 # Conservative to avoid overwhelming Plex API + # Threading control - get from config or default to 5 + database_config = config_manager.get('database', {}) + self.max_workers = database_config.get('max_workers', 5) + logger.info(f"Using {self.max_workers} worker threads for database update") self.thread_lock = threading.Lock() # Database instance diff --git a/database/__pycache__/music_database.cpython-312.pyc b/database/__pycache__/music_database.cpython-312.pyc index 82c3f9b7..b7e876ad 100644 Binary files a/database/__pycache__/music_database.cpython-312.pyc and b/database/__pycache__/music_database.cpython-312.pyc differ diff --git a/database/music_database.py b/database/music_database.py index 58ffa6c0..338e7165 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -54,19 +54,19 @@ class MusicDatabase: 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 + """Get a NEW database connection for each operation (thread-safe)""" + connection = sqlite3.connect(str(self.database_path), timeout=30.0) + connection.row_factory = sqlite3.Row + # Enable foreign key constraints and WAL mode for better concurrency + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("PRAGMA busy_timeout = 30000") # 30 second timeout + return connection def _initialize_database(self): """Create database tables if they don't exist""" @@ -138,31 +138,30 @@ class MusicDatabase: raise def close(self): - """Close database connection""" - if self._connection: - self._connection.close() - self._connection = None + """Close database connection (no-op since we create connections per operation)""" + # Each operation creates and closes its own connection, so nothing to do here + pass 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 - } + with self._get_connection() as conn: + 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} @@ -170,16 +169,21 @@ class MusicDatabase: 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") - + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute("DELETE FROM tracks") + cursor.execute("DELETE FROM albums") + cursor.execute("DELETE FROM artists") + + conn.commit() + + # VACUUM to actually shrink the database file and reclaim disk space + logger.info("Vacuuming database to reclaim disk space...") + cursor.execute("VACUUM") + + logger.info("All database data cleared and file compacted") + except Exception as e: logger.error(f"Error clearing database: {e}") raise @@ -188,43 +192,43 @@ class MusicDatabase: 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 - + with self._get_connection() as conn: + 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 @@ -232,25 +236,25 @@ class MusicDatabase: 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 - + with self._get_connection() as conn: + 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 diff --git a/ui/pages/__pycache__/settings.cpython-312.pyc b/ui/pages/__pycache__/settings.cpython-312.pyc index bbcbd768..d29e439e 100644 Binary files a/ui/pages/__pycache__/settings.cpython-312.pyc and b/ui/pages/__pycache__/settings.cpython-312.pyc differ diff --git a/ui/pages/settings.py b/ui/pages/settings.py index 2d9d8682..90e96f75 100644 --- a/ui/pages/settings.py +++ b/ui/pages/settings.py @@ -562,6 +562,15 @@ class SettingsPage(QWidget): self.download_path_input.setText(soulseek_config.get('download_path', './downloads')) self.transfer_path_input.setText(soulseek_config.get('transfer_path', './Transfer')) + # Load database config + database_config = config_manager.get('database', {}) + if hasattr(self, 'max_workers_combo'): + max_workers = database_config.get('max_workers', 5) + # Find the index of the current value in the combo box + index = self.max_workers_combo.findText(str(max_workers)) + if index >= 0: + self.max_workers_combo.setCurrentIndex(index) + # Load logging config (read-only display) logging_config = config_manager.get_logging_config() if hasattr(self, 'log_level_display'): @@ -590,6 +599,11 @@ class SettingsPage(QWidget): config_manager.set('soulseek.download_path', self.download_path_input.text()) config_manager.set('soulseek.transfer_path', self.transfer_path_input.text()) + # Save Database settings + if hasattr(self, 'max_workers_combo'): + max_workers = int(self.max_workers_combo.currentText()) + config_manager.set('database.max_workers', max_workers) + # Emit signals for path changes to update other pages immediately self.settings_changed.emit('soulseek.download_path', self.download_path_input.text()) self.settings_changed.emit('soulseek.transfer_path', self.transfer_path_input.text()) @@ -1257,6 +1271,34 @@ class SettingsPage(QWidget): download_layout.addLayout(path_container) download_layout.addLayout(transfer_path_container) + # Database Settings + database_group = SettingsGroup("Database Settings") + database_layout = QVBoxLayout(database_group) + database_layout.setContentsMargins(16, 20, 16, 16) + database_layout.setSpacing(12) + + # Max Workers + workers_layout = QHBoxLayout() + workers_label = QLabel("Concurrent Workers:") + workers_label.setStyleSheet("color: #ffffff; font-size: 12px;") + + self.max_workers_combo = QComboBox() + self.max_workers_combo.addItems(["3", "4", "5", "6", "7", "8", "9", "10"]) + self.max_workers_combo.setCurrentText("5") # Default value + self.max_workers_combo.setStyleSheet(self.get_combo_style()) + self.max_workers_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + workers_layout.addWidget(workers_label) + workers_layout.addWidget(self.max_workers_combo) + + # Help text for workers + workers_help = QLabel("Number of parallel threads for database updates. Higher values = faster updates but more server load.") + workers_help.setStyleSheet("color: #888888; font-size: 10px; font-style: italic;") + workers_help.setWordWrap(True) + + database_layout.addLayout(workers_layout) + database_layout.addWidget(workers_help) + # Logging Settings logging_group = SettingsGroup("Logging Settings") logging_layout = QVBoxLayout(logging_group) @@ -1305,6 +1347,7 @@ class SettingsPage(QWidget): logging_layout.addLayout(log_path_container) layout.addWidget(download_group) + layout.addWidget(database_group) layout.addWidget(logging_group) layout.addStretch() # Push content to top, prevent stretching