mirror of https://github.com/Nezreka/SoulSync.git
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.
462 lines
20 KiB
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()
|