Introduces API endpoints and UI elements to view and change the application's log level from the web interface. Log level changes are applied immediately and persisted in the database. Updates backend logic, logging utilities, and frontend scripts to support this feature.
461 lines
20 KiB
Python
461 lines
20 KiB
Python
|
|
#!/usr/bin/env python3
|
|
|
|
import sys
|
|
import asyncio
|
|
import time
|
|
from pathlib import Path
|
|
from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QStackedWidget
|
|
from PyQt6.QtCore import QThread, pyqtSignal, QTimer, QThreadPool
|
|
from PyQt6.QtGui import QFont, QPalette, QColor
|
|
|
|
from config.settings import config_manager
|
|
from utils.logging_config import setup_logging, get_logger
|
|
from core.spotify_client import SpotifyClient
|
|
from core.plex_client import PlexClient
|
|
from core.jellyfin_client import JellyfinClient
|
|
from core.navidrome_client import NavidromeClient
|
|
from core.soulseek_client import SoulseekClient
|
|
|
|
from ui.sidebar import ModernSidebar
|
|
from ui.pages.dashboard import DashboardPage
|
|
from ui.pages.sync import SyncPage
|
|
from ui.pages.downloads import DownloadsPage
|
|
from ui.pages.artists import ArtistsPage
|
|
from ui.pages.settings import SettingsPage
|
|
from ui.components.toast_manager import ToastManager
|
|
|
|
logger = get_logger("main")
|
|
|
|
class ServiceStatusThread(QThread):
|
|
status_updated = pyqtSignal(str, bool)
|
|
|
|
def __init__(self, spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client):
|
|
super().__init__()
|
|
self.spotify_client = spotify_client
|
|
self.plex_client = plex_client
|
|
self.jellyfin_client = jellyfin_client
|
|
self.navidrome_client = navidrome_client
|
|
self.soulseek_client = soulseek_client
|
|
self.running = True
|
|
|
|
# Import here to avoid circular imports
|
|
from config.settings import config_manager
|
|
self.config_manager = config_manager
|
|
|
|
def run(self):
|
|
while self.running:
|
|
try:
|
|
# Check Spotify authentication - but don't trigger OAuth
|
|
spotify_status = self.spotify_client.sp is not None
|
|
self.status_updated.emit("spotify", spotify_status)
|
|
|
|
# Check active media server connection
|
|
active_server = self.config_manager.get_active_media_server()
|
|
if active_server == "plex":
|
|
server_status = self.plex_client.is_connected()
|
|
self.status_updated.emit("plex", server_status)
|
|
elif active_server == "jellyfin":
|
|
# Use the JellyfinClient for status checking
|
|
jellyfin_status = self.jellyfin_client.is_connected()
|
|
self.status_updated.emit("jellyfin", jellyfin_status)
|
|
elif active_server == "navidrome":
|
|
# Use the NavidromeClient for status checking
|
|
navidrome_status = self.navidrome_client.is_connected()
|
|
self.status_updated.emit("navidrome", navidrome_status)
|
|
|
|
# Check Soulseek connection (simplified check to avoid event loop issues)
|
|
soulseek_status = self.soulseek_client.is_configured()
|
|
self.status_updated.emit("soulseek", soulseek_status)
|
|
|
|
self.msleep(10000) # Check every 10 seconds (less aggressive)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking service status: {e}")
|
|
self.msleep(10000)
|
|
|
|
def stop(self):
|
|
self.running = False
|
|
self.quit()
|
|
self.wait(2000) # Wait max 2 seconds
|
|
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
# Track application start time for uptime calculation
|
|
self.app_start_time = time.time()
|
|
|
|
self.spotify_client = SpotifyClient()
|
|
self.plex_client = PlexClient()
|
|
self.jellyfin_client = JellyfinClient()
|
|
self.navidrome_client = NavidromeClient()
|
|
self.soulseek_client = SoulseekClient()
|
|
|
|
self.status_thread = None
|
|
self.init_ui()
|
|
self.setup_status_monitoring()
|
|
|
|
# Setup periodic search maintenance (rolling 50-search window)
|
|
self.setup_search_maintenance()
|
|
|
|
def setup_search_maintenance(self):
|
|
"""Setup periodic search history maintenance to keep only the 50 most recent searches"""
|
|
try:
|
|
# Create timer for periodic search maintenance
|
|
self.search_maintenance_timer = QTimer()
|
|
self.search_maintenance_timer.timeout.connect(self._run_search_maintenance)
|
|
|
|
# Run maintenance every 2 minutes (120 seconds)
|
|
# This keeps search history clean without being too frequent
|
|
self.search_maintenance_timer.start(120000)
|
|
|
|
logger.info("Search maintenance timer started (every 2 minutes, keeps 200 most recent searches)")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error setting up search maintenance: {e}")
|
|
|
|
def _run_search_maintenance(self):
|
|
"""Run search maintenance in background thread to avoid blocking UI"""
|
|
try:
|
|
# Only run if Soulseek client seems to be available
|
|
if hasattr(self.soulseek_client, 'base_url') and self.soulseek_client.base_url:
|
|
# Run maintenance in background thread
|
|
import threading
|
|
|
|
def maintenance_thread():
|
|
try:
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
# Run the maintenance (keep 200 most recent searches)
|
|
success = loop.run_until_complete(self.soulseek_client.maintain_search_history(200))
|
|
|
|
if not success:
|
|
logger.warning("Search maintenance completed with some failures")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in search maintenance thread: {e}")
|
|
finally:
|
|
loop.close()
|
|
|
|
thread = threading.Thread(target=maintenance_thread, daemon=True)
|
|
thread.start()
|
|
else:
|
|
logger.debug("Soulseek client not configured, skipping search maintenance")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error running search maintenance: {e}")
|
|
|
|
def init_ui(self):
|
|
self.setWindowTitle("SoulSync - Music Sync & Manager")
|
|
self.setGeometry(100, 100, 1400, 900)
|
|
|
|
# Set dark theme palette
|
|
self.setStyleSheet("""
|
|
QMainWindow {
|
|
background: #121212;
|
|
}
|
|
""")
|
|
|
|
# Create central widget
|
|
central_widget = QWidget()
|
|
self.setCentralWidget(central_widget)
|
|
|
|
# Main layout
|
|
main_layout = QHBoxLayout(central_widget)
|
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
main_layout.setSpacing(0)
|
|
|
|
# Create sidebar
|
|
self.sidebar = ModernSidebar()
|
|
self.sidebar.page_changed.connect(self.change_page)
|
|
main_layout.addWidget(self.sidebar)
|
|
|
|
# Create stacked widget for pages
|
|
self.stacked_widget = QStackedWidget()
|
|
|
|
# Create toast manager
|
|
self.toast_manager = ToastManager(self)
|
|
|
|
# Create and add pages
|
|
self.dashboard_page = DashboardPage()
|
|
self.downloads_page = DownloadsPage(self.soulseek_client)
|
|
self.sync_page = SyncPage(
|
|
spotify_client=self.spotify_client,
|
|
plex_client=self.plex_client,
|
|
soulseek_client=self.soulseek_client,
|
|
downloads_page=self.downloads_page,
|
|
jellyfin_client=self.jellyfin_client,
|
|
navidrome_client=self.navidrome_client
|
|
)
|
|
self.artists_page = ArtistsPage(downloads_page=self.downloads_page)
|
|
self.settings_page = SettingsPage()
|
|
|
|
# Set toast manager for pages that need direct access
|
|
self.downloads_page.set_toast_manager(self.toast_manager)
|
|
self.sync_page.set_toast_manager(self.toast_manager)
|
|
self.artists_page.set_toast_manager(self.toast_manager)
|
|
self.settings_page.set_toast_manager(self.toast_manager)
|
|
|
|
# Configure dashboard with service clients and page references
|
|
self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.jellyfin_client, self.navidrome_client, self.soulseek_client)
|
|
self.dashboard_page.set_page_references(self.downloads_page, self.sync_page)
|
|
self.dashboard_page.set_app_start_time(self.app_start_time)
|
|
self.dashboard_page.set_toast_manager(self.toast_manager)
|
|
|
|
# Connect download completion signal for session tracking
|
|
self.downloads_page.download_session_completed.connect(
|
|
self.dashboard_page.data_provider.increment_completed_downloads
|
|
)
|
|
|
|
# Connect sync activities to dashboard
|
|
self.sync_page.sync_activity.connect(
|
|
self.dashboard_page.add_activity_item
|
|
)
|
|
|
|
# Connect download activities to dashboard
|
|
self.downloads_page.download_activity.connect(
|
|
self.dashboard_page.add_activity_item
|
|
)
|
|
|
|
# --- ADD THESE TWO LINES TO FIX THE UI UPDATE ---
|
|
self.sync_page.database_updated_externally.connect(self.dashboard_page.database_updated_externally)
|
|
self.artists_page.database_updated_externally.connect(self.dashboard_page.database_updated_externally)
|
|
# ------------------------------------------------
|
|
|
|
self.stacked_widget.addWidget(self.dashboard_page)
|
|
self.stacked_widget.addWidget(self.sync_page)
|
|
self.stacked_widget.addWidget(self.downloads_page)
|
|
self.stacked_widget.addWidget(self.artists_page)
|
|
self.stacked_widget.addWidget(self.settings_page)
|
|
|
|
main_layout.addWidget(self.stacked_widget)
|
|
|
|
# Set dashboard as default page
|
|
self.change_page("dashboard")
|
|
|
|
# Connect media player signals between sidebar and downloads page
|
|
self.setup_media_player_connections()
|
|
|
|
# Connect settings change signals for live updates
|
|
self.setup_settings_connections()
|
|
|
|
def setup_status_monitoring(self):
|
|
# Start status monitoring thread
|
|
self.status_thread = ServiceStatusThread(
|
|
self.spotify_client,
|
|
self.plex_client,
|
|
self.jellyfin_client,
|
|
self.navidrome_client,
|
|
self.soulseek_client
|
|
)
|
|
self.status_thread.status_updated.connect(self.update_service_status)
|
|
self.status_thread.start()
|
|
|
|
def setup_media_player_connections(self):
|
|
"""Connect signals between downloads page and sidebar media player"""
|
|
# Connect downloads page signals to sidebar media player
|
|
self.downloads_page.track_started.connect(self.sidebar.media_player.set_track_info)
|
|
self.downloads_page.track_paused.connect(lambda: self.sidebar.media_player.set_playing_state(False))
|
|
self.downloads_page.track_resumed.connect(lambda: self.sidebar.media_player.set_playing_state(True))
|
|
self.downloads_page.track_stopped.connect(self.sidebar.media_player.clear_track)
|
|
self.downloads_page.track_finished.connect(self.sidebar.media_player.clear_track)
|
|
|
|
# Connect loading animation signals
|
|
self.downloads_page.track_loading_started.connect(lambda result: self.sidebar.media_player.show_loading())
|
|
self.downloads_page.track_loading_finished.connect(lambda result: self.sidebar.media_player.hide_loading())
|
|
self.downloads_page.track_loading_progress.connect(lambda progress, result: self.sidebar.media_player.set_loading_progress(progress))
|
|
|
|
# Connect sidebar media player signals to downloads page
|
|
self.sidebar.media_player.play_pause_requested.connect(self.downloads_page.handle_sidebar_play_pause)
|
|
self.sidebar.media_player.stop_requested.connect(self.downloads_page.handle_sidebar_stop)
|
|
self.sidebar.media_player.volume_changed.connect(self.downloads_page.handle_sidebar_volume)
|
|
|
|
logger.info("Media player connections established between sidebar and downloads page")
|
|
|
|
def setup_settings_connections(self):
|
|
"""Connect settings change signals for live updates across pages"""
|
|
self.settings_page.settings_changed.connect(self.on_settings_changed)
|
|
logger.info("Settings change connections established")
|
|
|
|
def on_settings_changed(self, key: str, value: str):
|
|
"""Handle settings changes and broadcast to relevant pages"""
|
|
# Reinitialize service clients when their settings change
|
|
if key.startswith('spotify.'):
|
|
try:
|
|
self.spotify_client._setup_client()
|
|
except Exception as e:
|
|
logger.error("Failed to reinitialize Spotify client")
|
|
|
|
elif key.startswith('plex.'):
|
|
try:
|
|
# Reset Plex connection to force reconnection with new settings
|
|
self.plex_client.server = None
|
|
self.plex_client.music_library = None
|
|
self.plex_client._connection_attempted = False
|
|
except Exception as e:
|
|
logger.error("Failed to reset Plex client")
|
|
|
|
elif key.startswith('soulseek.'):
|
|
try:
|
|
self.soulseek_client._setup_client()
|
|
except Exception as e:
|
|
logger.error("Failed to reinitialize Soulseek client")
|
|
|
|
# Broadcast to all pages that need to know about path changes
|
|
if hasattr(self.downloads_page, 'on_paths_updated'):
|
|
self.downloads_page.on_paths_updated(key, value)
|
|
if hasattr(self.artists_page, 'on_paths_updated'):
|
|
self.artists_page.on_paths_updated(key, value)
|
|
|
|
def change_page(self, page_id: str):
|
|
page_map = {
|
|
"dashboard": 0,
|
|
"sync": 1,
|
|
"downloads": 2,
|
|
"artists": 3,
|
|
"settings": 4
|
|
}
|
|
|
|
if page_id in page_map:
|
|
self.stacked_widget.setCurrentIndex(page_map[page_id])
|
|
logger.info(f"Changed to page: {page_id}")
|
|
|
|
def update_service_status(self, service: str, connected: bool):
|
|
self.sidebar.update_service_status(service, connected)
|
|
|
|
# Update dashboard with service status
|
|
if hasattr(self.dashboard_page, 'data_provider'):
|
|
self.dashboard_page.data_provider.update_service_status(service, connected)
|
|
|
|
# Force a refresh of the Spotify client if needed
|
|
if service == "spotify" and not connected:
|
|
try:
|
|
self.spotify_client._setup_client()
|
|
except Exception as e:
|
|
logger.error(f"Error refreshing Spotify client: {e}")
|
|
|
|
def closeEvent(self, event):
|
|
logger.info("Closing application...")
|
|
|
|
try:
|
|
# Stop all page threads first
|
|
if hasattr(self, 'downloads_page') and self.downloads_page:
|
|
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 other page threads and background tasks
|
|
if hasattr(self, 'artists_page') and self.artists_page:
|
|
logger.info("Cleaning up Artists page threads...")
|
|
if hasattr(self.artists_page, 'cleanup_threads'):
|
|
self.artists_page.cleanup_threads()
|
|
|
|
if hasattr(self, 'sync_page') and self.sync_page:
|
|
logger.info("Cleaning up Sync page threads...")
|
|
if hasattr(self.sync_page, 'cleanup_threads'):
|
|
self.sync_page.cleanup_threads()
|
|
|
|
if hasattr(self, 'downloads_page') and self.downloads_page:
|
|
logger.info("Cleaning up Downloads page threads...")
|
|
if hasattr(self.downloads_page, 'cleanup_threads'):
|
|
self.downloads_page.cleanup_threads()
|
|
|
|
# Stop all QThreadPool tasks
|
|
logger.info("Stopping global thread pool...")
|
|
QThreadPool.globalInstance().clear()
|
|
QThreadPool.globalInstance().waitForDone(1000) # Wait max 1 second
|
|
|
|
# Stop status monitoring thread
|
|
if self.status_thread:
|
|
logger.info("Stopping status monitoring thread...")
|
|
self.status_thread.stop()
|
|
|
|
# Stop search maintenance timer
|
|
if hasattr(self, 'search_maintenance_timer') and self.search_maintenance_timer:
|
|
logger.info("Stopping search maintenance timer...")
|
|
self.search_maintenance_timer.stop()
|
|
|
|
# Close Soulseek client
|
|
try:
|
|
logger.info("Closing Soulseek client...")
|
|
# Use modern asyncio approach instead of deprecated get_event_loop
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
# Create a new task to close the client
|
|
task = asyncio.create_task(self.soulseek_client.close())
|
|
# Wait for it to complete
|
|
asyncio.run_coroutine_threadsafe(self.soulseek_client.close(), loop).result(timeout=3.0)
|
|
except RuntimeError:
|
|
# No running loop, create new one
|
|
asyncio.run(self.soulseek_client.close())
|
|
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()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during application shutdown: {e}")
|
|
# Force accept the event to prevent hanging
|
|
event.accept()
|
|
|
|
def main():
|
|
# Check for saved log level preference in database
|
|
try:
|
|
from database.music_database import MusicDatabase
|
|
db = MusicDatabase()
|
|
saved_log_level = db.get_preference('log_level')
|
|
if saved_log_level:
|
|
log_level = saved_log_level
|
|
else:
|
|
# Fall back to config file
|
|
logging_config = config_manager.get_logging_config()
|
|
log_level = logging_config.get('level', 'INFO')
|
|
except:
|
|
# If database isn't available yet, use config file
|
|
logging_config = config_manager.get_logging_config()
|
|
log_level = logging_config.get('level', 'INFO')
|
|
|
|
logging_config = config_manager.get_logging_config()
|
|
log_file = logging_config.get('path', 'logs/newmusic.log')
|
|
setup_logging(level=log_level, log_file=log_file)
|
|
|
|
logger.info("Starting Soulsync application")
|
|
|
|
if not config_manager.config_path.exists():
|
|
logger.error("Configuration file not found. Please check config/config.json")
|
|
sys.exit(1)
|
|
|
|
app = QApplication(sys.argv)
|
|
app.setApplicationName("SoulSync")
|
|
app.setApplicationVersion("0.6")
|
|
|
|
main_window = MainWindow()
|
|
main_window.show()
|
|
|
|
try:
|
|
sys.exit(app.exec())
|
|
except KeyboardInterrupt:
|
|
logger.info("Application interrupted by user")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error: {e}")
|
|
sys.exit(1)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|