You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/main.py

462 lines
20 KiB

#!/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()