Remove desktop app
Development has shifted fully towards the web application, so removing the desktop app so it doesn't cause any confusion in the codebase
This commit is contained in:
parent
2429d87dbe
commit
a17e1030d3
17 changed files with 0 additions and 38990 deletions
|
|
@ -1,389 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import threading
|
||||
import time
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("media_scan_manager")
|
||||
|
||||
class MediaScanManager:
|
||||
"""
|
||||
Smart media library scan manager with debouncing and scan-aware follow-up logic.
|
||||
Supports both Plex and Jellyfin servers based on active configuration.
|
||||
|
||||
Features:
|
||||
- Debounces multiple scan requests to prevent spam
|
||||
- Tracks downloads that happen during active scans
|
||||
- Automatically triggers follow-up scans when needed
|
||||
- Thread-safe operation
|
||||
- Works with both Plex and Jellyfin
|
||||
"""
|
||||
|
||||
def __init__(self, delay_seconds: int = 60):
|
||||
"""
|
||||
Initialize the scan manager.
|
||||
|
||||
Args:
|
||||
delay_seconds: Debounce delay in seconds (default 60s)
|
||||
"""
|
||||
self.delay = delay_seconds
|
||||
self._timer = None
|
||||
self._scan_in_progress = False
|
||||
self._downloads_during_scan = False
|
||||
self._lock = threading.Lock()
|
||||
self._scan_completion_callbacks = [] # List of callback functions to call when scan completes
|
||||
self._scan_start_time = None # Track when scan started for timeout
|
||||
self._max_scan_time = 1800 # Maximum scan time in seconds (30 minutes)
|
||||
|
||||
# New periodic update system
|
||||
self._periodic_update_timer = None # Timer for 5-minute periodic updates
|
||||
self._periodic_update_interval = 300 # 5 minutes in seconds
|
||||
self._is_doing_periodic_updates = False # Track if we're in periodic update mode
|
||||
self._shutting_down = False
|
||||
|
||||
logger.info(f"MediaScanManager initialized with {delay_seconds}s debounce delay")
|
||||
|
||||
def _get_active_media_client(self):
|
||||
"""Get the active media client based on config settings"""
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
active_server = config_manager.get_active_media_server()
|
||||
|
||||
# Try to get client instances from app
|
||||
try:
|
||||
# Try PyQt6 first (GUI mode)
|
||||
try:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
app = QApplication.instance()
|
||||
|
||||
if app:
|
||||
# Try to find the main window from top-level widgets
|
||||
main_window = None
|
||||
for widget in app.topLevelWidgets():
|
||||
if (hasattr(widget, 'plex_client') and hasattr(widget, 'jellyfin_client') and
|
||||
hasattr(widget, 'navidrome_client')):
|
||||
main_window = widget
|
||||
break
|
||||
|
||||
if main_window:
|
||||
server_attr_map = {
|
||||
'jellyfin': 'jellyfin_client',
|
||||
'navidrome': 'navidrome_client',
|
||||
'plex': 'plex_client'
|
||||
}
|
||||
client_attr = server_attr_map.get(active_server)
|
||||
if client_attr:
|
||||
client = getattr(main_window, client_attr, None)
|
||||
if client and client.is_connected():
|
||||
return client, active_server
|
||||
else:
|
||||
logger.warning(f"{active_server.title()} client not connected — scan skipped")
|
||||
else:
|
||||
logger.debug("No main window found in Qt application")
|
||||
else:
|
||||
logger.debug("No QApplication instance found")
|
||||
|
||||
except ImportError:
|
||||
logger.debug("PyQt6 not available, trying headless mode")
|
||||
|
||||
# Headless mode - try to get clients from global instances
|
||||
import sys
|
||||
server_attr_map = {
|
||||
'jellyfin': 'jellyfin_client',
|
||||
'navidrome': 'navidrome_client',
|
||||
'plex': 'plex_client'
|
||||
}
|
||||
client_attr = server_attr_map.get(active_server)
|
||||
if client_attr:
|
||||
for module_name, module in sys.modules.items():
|
||||
if hasattr(module, client_attr):
|
||||
client = getattr(module, client_attr, None)
|
||||
if client and hasattr(client, 'is_connected') and client.is_connected():
|
||||
return client, active_server
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not access clients: {e}")
|
||||
|
||||
logger.error("No active media client available")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error(f"Error determining active media server: {e}")
|
||||
return None, None
|
||||
|
||||
def request_scan(self, reason: str = "Download completed"):
|
||||
"""
|
||||
Request a library scan with smart debouncing logic.
|
||||
|
||||
Args:
|
||||
reason: Optional reason for the scan request (for logging)
|
||||
"""
|
||||
logger.info(f"DEBUG: Media scan requested - reason: {reason}")
|
||||
with self._lock:
|
||||
if self._shutting_down:
|
||||
logger.debug("Media scan request ignored during shutdown")
|
||||
return
|
||||
if self._scan_in_progress:
|
||||
# Server is currently scanning - mark that we need another scan later
|
||||
self._downloads_during_scan = True
|
||||
logger.info(f"Media scan in progress - queueing follow-up scan ({reason})")
|
||||
return
|
||||
|
||||
# Cancel any existing timer and start a new one
|
||||
if self._timer:
|
||||
self._timer.cancel()
|
||||
logger.debug(f"Resetting scan timer ({reason})")
|
||||
else:
|
||||
logger.info(f"Media scan queued - will execute in {self.delay}s ({reason})")
|
||||
|
||||
# Start the debounce timer
|
||||
self._timer = threading.Timer(self.delay, self._execute_scan)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
|
||||
def add_scan_completion_callback(self, callback):
|
||||
"""
|
||||
Add a callback function to be called when scan completes.
|
||||
|
||||
Args:
|
||||
callback: Function to call when scan completes (no arguments)
|
||||
"""
|
||||
with self._lock:
|
||||
if callback not in self._scan_completion_callbacks:
|
||||
self._scan_completion_callbacks.append(callback)
|
||||
logger.info(f"DEBUG: Added scan completion callback: {callback.__name__}")
|
||||
logger.info(f"DEBUG: Total callbacks registered: {len(self._scan_completion_callbacks)}")
|
||||
|
||||
def remove_scan_completion_callback(self, callback):
|
||||
"""
|
||||
Remove a previously registered callback.
|
||||
|
||||
Args:
|
||||
callback: Function to remove from callbacks
|
||||
"""
|
||||
with self._lock:
|
||||
if callback in self._scan_completion_callbacks:
|
||||
self._scan_completion_callbacks.remove(callback)
|
||||
logger.debug(f"Removed scan completion callback: {callback.__name__}")
|
||||
|
||||
def _execute_scan(self):
|
||||
"""Execute the actual media library scan"""
|
||||
with self._lock:
|
||||
if self._shutting_down:
|
||||
logger.debug("Media scan execution skipped during shutdown")
|
||||
return
|
||||
if self._scan_in_progress:
|
||||
logger.warning("Scan already in progress - skipping duplicate execution")
|
||||
return
|
||||
|
||||
self._scan_in_progress = True
|
||||
self._downloads_during_scan = False
|
||||
self._timer = None
|
||||
self._scan_start_time = time.time()
|
||||
|
||||
# Get the active media client
|
||||
media_client, server_type = self._get_active_media_client()
|
||||
if not media_client:
|
||||
logger.error("No active media client available for library scan")
|
||||
self._reset_scan_state()
|
||||
return
|
||||
|
||||
logger.info(f"Starting {server_type.upper()} library scan...")
|
||||
|
||||
try:
|
||||
success = media_client.trigger_library_scan()
|
||||
|
||||
if success:
|
||||
logger.info(f"{server_type.upper()} library scan initiated successfully")
|
||||
# Start new periodic update system instead of completion detection
|
||||
self._start_periodic_updates()
|
||||
else:
|
||||
logger.error(f"Failed to initiate {server_type.upper()} library scan")
|
||||
self._reset_scan_state()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Exception during {server_type.upper()} library scan: {e}")
|
||||
self._reset_scan_state()
|
||||
|
||||
def _start_periodic_updates(self):
|
||||
"""Start periodic database updates while media server is scanning"""
|
||||
try:
|
||||
with self._lock:
|
||||
if self._is_doing_periodic_updates:
|
||||
logger.debug("Periodic updates already in progress")
|
||||
return
|
||||
|
||||
self._is_doing_periodic_updates = True
|
||||
|
||||
logger.info(f"Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
|
||||
|
||||
# Schedule first periodic update after 5 minutes
|
||||
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
||||
self._periodic_update_timer.daemon = True
|
||||
self._periodic_update_timer.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting periodic updates: {e}")
|
||||
self._reset_scan_state()
|
||||
|
||||
def _do_periodic_update(self):
|
||||
"""Execute periodic database update and check if scanning continues"""
|
||||
try:
|
||||
with self._lock:
|
||||
if not self._scan_in_progress:
|
||||
logger.debug("Scan no longer in progress, stopping periodic updates")
|
||||
return
|
||||
|
||||
# Check for timeout
|
||||
if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time:
|
||||
logger.warning(f"Media scan timeout reached ({self._max_scan_time}s), stopping periodic updates")
|
||||
self._stop_periodic_updates()
|
||||
return
|
||||
|
||||
# Get the active media client
|
||||
media_client, server_type = self._get_active_media_client()
|
||||
if not media_client:
|
||||
logger.warning("No active media client available for scan status check")
|
||||
self._stop_periodic_updates()
|
||||
return
|
||||
|
||||
# Check if media server is still scanning
|
||||
is_scanning = media_client.is_library_scanning("Music")
|
||||
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
||||
|
||||
logger.info(f"PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - {server_type.upper()} scanning: {is_scanning}")
|
||||
|
||||
if is_scanning:
|
||||
# Still scanning - trigger database update and continue periodic updates
|
||||
logger.info(f"{server_type.upper()} still scanning - triggering database update")
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._call_completion_callbacks()
|
||||
|
||||
# Schedule next periodic update
|
||||
if self._shutting_down:
|
||||
return
|
||||
logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
|
||||
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
|
||||
self._periodic_update_timer.daemon = True
|
||||
self._periodic_update_timer.start()
|
||||
else:
|
||||
# Scanning stopped - final update and cleanup
|
||||
logger.info(f"{server_type.upper()} scanning completed - doing final database update")
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._call_completion_callbacks()
|
||||
self._stop_periodic_updates()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during periodic update: {e}")
|
||||
self._stop_periodic_updates()
|
||||
|
||||
def _stop_periodic_updates(self):
|
||||
"""Stop periodic updates and clean up"""
|
||||
try:
|
||||
with self._lock:
|
||||
self._is_doing_periodic_updates = False
|
||||
|
||||
if self._periodic_update_timer:
|
||||
self._periodic_update_timer.cancel()
|
||||
self._periodic_update_timer = None
|
||||
|
||||
logger.info("Stopped periodic database updates")
|
||||
self._scan_completed()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping periodic updates: {e}")
|
||||
|
||||
def _scan_completed(self):
|
||||
"""Called when we assume the scan has completed"""
|
||||
with self._lock:
|
||||
was_in_progress = self._scan_in_progress
|
||||
downloads_during_scan = self._downloads_during_scan
|
||||
|
||||
# Reset scan state
|
||||
self._scan_in_progress = False
|
||||
|
||||
if not was_in_progress:
|
||||
logger.debug("Scan completion callback called but scan was not in progress")
|
||||
return
|
||||
|
||||
logger.info("Media library scan completed")
|
||||
|
||||
# Call registered completion callbacks
|
||||
self._call_completion_callbacks()
|
||||
|
||||
# Check if we need a follow-up scan
|
||||
if downloads_during_scan:
|
||||
logger.info("Downloads occurred during scan - triggering follow-up scan")
|
||||
self.request_scan("Follow-up scan for downloads during previous scan")
|
||||
else:
|
||||
logger.info("No downloads during scan - scan cycle complete")
|
||||
|
||||
def _call_completion_callbacks(self):
|
||||
"""Call all registered scan completion callbacks"""
|
||||
with self._lock:
|
||||
callbacks = self._scan_completion_callbacks.copy() # Copy to avoid lock issues
|
||||
|
||||
logger.info(f"DEBUG: Calling {len(callbacks)} scan completion callbacks")
|
||||
for callback in callbacks:
|
||||
try:
|
||||
logger.info(f"DEBUG: Executing callback: {callback.__name__}")
|
||||
callback()
|
||||
logger.info(f"DEBUG: Callback {callback.__name__} completed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scan completion callback {callback.__name__}: {e}")
|
||||
|
||||
def _reset_scan_state(self):
|
||||
"""Reset scan state after an error"""
|
||||
with self._lock:
|
||||
self._scan_in_progress = False
|
||||
self._scan_start_time = None
|
||||
|
||||
# Cancel periodic updates if running
|
||||
if self._periodic_update_timer:
|
||||
self._periodic_update_timer.cancel()
|
||||
self._periodic_update_timer = None
|
||||
self._is_doing_periodic_updates = False
|
||||
|
||||
def force_scan(self):
|
||||
"""
|
||||
Force an immediate scan, bypassing debouncing.
|
||||
Use sparingly - mainly for manual/administrative triggers.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._timer:
|
||||
self._timer.cancel()
|
||||
self._timer = None
|
||||
|
||||
if self._scan_in_progress:
|
||||
logger.warning("Force scan requested but scan already in progress")
|
||||
return
|
||||
|
||||
logger.info("Force scan requested - executing immediately")
|
||||
self._execute_scan()
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get current status of the scan manager"""
|
||||
with self._lock:
|
||||
return {
|
||||
'scan_in_progress': self._scan_in_progress,
|
||||
'downloads_during_scan': self._downloads_during_scan,
|
||||
'timer_active': self._timer is not None,
|
||||
'delay_seconds': self.delay
|
||||
}
|
||||
|
||||
def shutdown(self):
|
||||
"""Clean shutdown - cancel any pending timers"""
|
||||
with self._lock:
|
||||
self._shutting_down = True
|
||||
if self._timer:
|
||||
self._timer.cancel()
|
||||
self._timer = None
|
||||
|
||||
if self._periodic_update_timer:
|
||||
self._periodic_update_timer.cancel()
|
||||
self._periodic_update_timer = None
|
||||
|
||||
self._is_doing_periodic_updates = False
|
||||
logger.info("MediaScanManager shutdown - cancelled all pending timers")
|
||||
461
main.py
461
main.py
|
|
@ -1,461 +0,0 @@
|
|||
|
||||
#!/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()
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
PyQt6[multimedia]>=6.6.0
|
||||
spotipy>=2.23.0
|
||||
PlexAPI>=4.17.0
|
||||
requests>=2.31.0
|
||||
asyncio-mqtt>=0.16.0
|
||||
python-dotenv>=1.0.0
|
||||
cryptography>=41.0.0
|
||||
mutagen>=1.47.0
|
||||
Pillow>=10.0.0
|
||||
aiohttp>=3.9.0
|
||||
unidecode>=1.3.8
|
||||
yt-dlp>=2024.12.13
|
||||
Flask>=3.0.0
|
||||
Flask-Limiter>=3.5.0
|
||||
lrclibapi>=0.3.1
|
||||
pyacoustid>=1.3.0
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
|
|
@ -1,396 +0,0 @@
|
|||
#!/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
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("database_updater_widget")
|
||||
|
||||
class DatabaseUpdaterWidget(QFrame):
|
||||
"""UI widget for updating SoulSync database with media server library data (Plex, Jellyfin, or Navidrome)"""
|
||||
|
||||
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 - dynamic based on active server
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
active_server = config_manager.get_active_media_server()
|
||||
if active_server == "jellyfin":
|
||||
server_name = "Jellyfin"
|
||||
elif active_server == "navidrome":
|
||||
server_name = "Navidrome"
|
||||
else:
|
||||
server_name = "Plex"
|
||||
except:
|
||||
server_name = "Plex" # Fallback
|
||||
|
||||
info_label = QLabel(f"Syncs your {server_name} 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)
|
||||
|
||||
# Recommendation label
|
||||
self.recommendation_label = QLabel("Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy")
|
||||
self.recommendation_label.setFont(QFont("Arial", 9))
|
||||
self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")
|
||||
self.recommendation_label.setWordWrap(True)
|
||||
|
||||
# Last full refresh label
|
||||
self.last_refresh_label = QLabel("")
|
||||
self.last_refresh_label.setFont(QFont("Arial", 8))
|
||||
self.last_refresh_label.setStyleSheet("color: #888888; margin-bottom: 5px;")
|
||||
self.last_refresh_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.addWidget(self.recommendation_label)
|
||||
layout.addWidget(self.last_refresh_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)
|
||||
|
||||
def update_last_refresh_info(self, last_refresh_date: str = None):
|
||||
"""Update the last refresh information with color-coded warnings"""
|
||||
if not last_refresh_date:
|
||||
self.last_refresh_label.setText("No full refresh recorded")
|
||||
self.last_refresh_label.setStyleSheet("color: #ff6666; margin-bottom: 5px; font-style: italic;")
|
||||
self._update_recommendation_urgency(urgent=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
last_date = datetime.fromisoformat(last_refresh_date.replace('Z', '+00:00'))
|
||||
days_ago = (datetime.now() - last_date.replace(tzinfo=None)).days
|
||||
|
||||
if days_ago == 0:
|
||||
time_text = "today"
|
||||
color = "#1db954" # Green
|
||||
urgent = False
|
||||
elif days_ago == 1:
|
||||
time_text = "yesterday"
|
||||
color = "#1db954" # Green
|
||||
urgent = False
|
||||
elif days_ago < 7:
|
||||
time_text = f"{days_ago} days ago"
|
||||
color = "#1db954" # Green
|
||||
urgent = False
|
||||
elif days_ago < 14:
|
||||
time_text = f"{days_ago} days ago"
|
||||
color = "#ffaa00" # Orange warning
|
||||
urgent = False
|
||||
else:
|
||||
time_text = f"{days_ago} days ago"
|
||||
color = "#ff6666" # Red warning
|
||||
urgent = True
|
||||
|
||||
self.last_refresh_label.setText(f"Last full refresh: {time_text}")
|
||||
self.last_refresh_label.setStyleSheet(f"color: {color}; margin-bottom: 5px;")
|
||||
self._update_recommendation_urgency(urgent=urgent)
|
||||
|
||||
except Exception:
|
||||
self.last_refresh_label.setText("Last full refresh: unknown")
|
||||
self.last_refresh_label.setStyleSheet("color: #888888; margin-bottom: 5px;")
|
||||
self._update_recommendation_urgency(urgent=False)
|
||||
|
||||
def _update_recommendation_urgency(self, urgent: bool = False):
|
||||
"""Update the recommendation label styling based on urgency"""
|
||||
if urgent:
|
||||
self.recommendation_label.setText("Recommended: Run a Full Refresh - it's been over 2 weeks!")
|
||||
self.recommendation_label.setStyleSheet("color: #ffffff; margin-bottom: 8px; padding: 6px 8px; background: #cc3300; border-radius: 4px;")
|
||||
else:
|
||||
self.recommendation_label.setText("Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy")
|
||||
self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
from PyQt6.QtWidgets import QWidget, QLabel, QHBoxLayout, QVBoxLayout, QGraphicsOpacityEffect
|
||||
from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve, pyqtSignal, QRect
|
||||
from PyQt6.QtGui import QFont, QPainter, QPaintEvent
|
||||
import time
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
|
||||
class ToastType(Enum):
|
||||
SUCCESS = "success"
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
|
||||
class Toast(QWidget):
|
||||
"""Individual toast notification widget"""
|
||||
closed = pyqtSignal(object) # Emits self when closing
|
||||
|
||||
def __init__(self, message: str, toast_type: ToastType = ToastType.INFO, duration: int = 4000, parent=None):
|
||||
super().__init__(parent)
|
||||
self.message = message
|
||||
self.toast_type = toast_type
|
||||
self.duration = duration
|
||||
self.created_time = time.time()
|
||||
|
||||
self.setup_ui()
|
||||
self.setup_animations()
|
||||
self.setup_auto_dismiss()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the toast UI"""
|
||||
self.setFixedHeight(60)
|
||||
self.setMinimumWidth(300)
|
||||
self.setMaximumWidth(400)
|
||||
|
||||
# Make the widget click-through for the background but clickable for the content
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
|
||||
|
||||
# Main layout
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# Icon label
|
||||
self.icon_label = QLabel()
|
||||
self.icon_label.setFont(QFont("Segoe UI", 14))
|
||||
self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.icon_label.setFixedSize(24, 24)
|
||||
|
||||
# Message label
|
||||
self.message_label = QLabel(self.message)
|
||||
self.message_label.setFont(QFont("Segoe UI", 10))
|
||||
self.message_label.setWordWrap(True)
|
||||
self.message_label.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
layout.addWidget(self.icon_label)
|
||||
layout.addWidget(self.message_label, 1)
|
||||
|
||||
# Apply styling based on toast type
|
||||
self.apply_styling()
|
||||
|
||||
def apply_styling(self):
|
||||
"""Apply styling based on toast type"""
|
||||
if self.toast_type == ToastType.SUCCESS:
|
||||
icon = ""
|
||||
accent_color = "#1db954" # Spotify green
|
||||
bg_color = "rgba(29, 185, 84, 0.15)"
|
||||
border_color = "rgba(29, 185, 84, 0.3)"
|
||||
elif self.toast_type == ToastType.ERROR:
|
||||
icon = ""
|
||||
accent_color = "#f04747"
|
||||
bg_color = "rgba(240, 71, 71, 0.15)"
|
||||
border_color = "rgba(240, 71, 71, 0.3)"
|
||||
elif self.toast_type == ToastType.WARNING:
|
||||
icon = ""
|
||||
accent_color = "#ffa500"
|
||||
bg_color = "rgba(255, 165, 0, 0.15)"
|
||||
border_color = "rgba(255, 165, 0, 0.3)"
|
||||
else: # INFO
|
||||
icon = "ℹ️"
|
||||
accent_color = "#5865f2"
|
||||
bg_color = "rgba(88, 101, 242, 0.15)"
|
||||
border_color = "rgba(88, 101, 242, 0.3)"
|
||||
|
||||
self.icon_label.setText(icon)
|
||||
|
||||
self.setStyleSheet(f"""
|
||||
Toast {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 rgba(45, 45, 45, 0.95),
|
||||
stop:1 rgba(35, 35, 35, 0.95));
|
||||
border: 1px solid {border_color};
|
||||
border-left: 3px solid {accent_color};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
""")
|
||||
|
||||
self.message_label.setStyleSheet(f"""
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
""")
|
||||
|
||||
def setup_animations(self):
|
||||
"""Setup slide-in and fade-out animations"""
|
||||
# Opacity effect for fade animations
|
||||
self.opacity_effect = QGraphicsOpacityEffect()
|
||||
self.setGraphicsEffect(self.opacity_effect)
|
||||
|
||||
# Slide-in animation (from right)
|
||||
self.slide_animation = QPropertyAnimation(self, b"geometry")
|
||||
self.slide_animation.setDuration(300)
|
||||
self.slide_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
|
||||
# Fade-out animation
|
||||
self.fade_animation = QPropertyAnimation(self.opacity_effect, b"opacity")
|
||||
self.fade_animation.setDuration(200)
|
||||
self.fade_animation.setEasingCurve(QEasingCurve.Type.OutQuad)
|
||||
|
||||
# Connect fade animation to close
|
||||
self.fade_animation.finished.connect(self._on_fade_complete)
|
||||
|
||||
def setup_auto_dismiss(self):
|
||||
"""Setup auto-dismiss timer"""
|
||||
if self.duration > 0:
|
||||
self.dismiss_timer = QTimer()
|
||||
self.dismiss_timer.setSingleShot(True)
|
||||
self.dismiss_timer.timeout.connect(self.dismiss)
|
||||
self.dismiss_timer.start(self.duration)
|
||||
|
||||
def show_at_position(self, target_rect: QRect):
|
||||
"""Show the toast with slide-in animation at the specified position"""
|
||||
# Start position (off-screen to the right)
|
||||
start_rect = QRect(target_rect.x() + 50, target_rect.y(), target_rect.width(), target_rect.height())
|
||||
|
||||
# Set initial position and show
|
||||
self.setGeometry(start_rect)
|
||||
self.show()
|
||||
|
||||
# Animate to target position
|
||||
self.slide_animation.setStartValue(start_rect)
|
||||
self.slide_animation.setEndValue(target_rect)
|
||||
self.slide_animation.start()
|
||||
|
||||
def dismiss(self):
|
||||
"""Dismiss the toast with fade-out animation"""
|
||||
if hasattr(self, 'dismiss_timer'):
|
||||
self.dismiss_timer.stop()
|
||||
|
||||
self.fade_animation.setStartValue(1.0)
|
||||
self.fade_animation.setEndValue(0.0)
|
||||
self.fade_animation.start()
|
||||
|
||||
def _on_fade_complete(self):
|
||||
"""Called when fade animation completes"""
|
||||
self.closed.emit(self)
|
||||
self.hide()
|
||||
self.deleteLater()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Handle click to dismiss"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.dismiss()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
class ToastManager(QWidget):
|
||||
"""Manages multiple toast notifications"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_widget = parent
|
||||
self.active_toasts: List[Toast] = []
|
||||
self.toast_spacing = 10
|
||||
self.margin_from_edge = 20
|
||||
|
||||
# Make this widget transparent and non-interactive
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
def show_toast(self, message: str, toast_type: ToastType = ToastType.INFO, duration: int = 4000):
|
||||
"""Show a new toast notification"""
|
||||
toast = Toast(message, toast_type, duration, self.parent_widget)
|
||||
toast.closed.connect(self._on_toast_closed)
|
||||
|
||||
# Calculate position for this toast
|
||||
position = self._calculate_toast_position(len(self.active_toasts))
|
||||
|
||||
# Add to active toasts list
|
||||
self.active_toasts.append(toast)
|
||||
|
||||
# Show the toast
|
||||
toast.show_at_position(position)
|
||||
|
||||
# Reposition existing toasts if needed
|
||||
self._reposition_existing_toasts()
|
||||
|
||||
def _calculate_toast_position(self, index: int) -> QRect:
|
||||
"""Calculate position for a toast at the given index"""
|
||||
if not self.parent_widget:
|
||||
return QRect(0, 0, 350, 60)
|
||||
|
||||
parent_rect = self.parent_widget.rect()
|
||||
toast_height = 60
|
||||
toast_width = 350
|
||||
|
||||
x = parent_rect.width() - toast_width - self.margin_from_edge
|
||||
y = self.margin_from_edge + (index * (toast_height + self.toast_spacing))
|
||||
|
||||
return QRect(x, y, toast_width, toast_height)
|
||||
|
||||
def _reposition_existing_toasts(self):
|
||||
"""Reposition existing toasts to make room for new ones"""
|
||||
for i, toast in enumerate(self.active_toasts[:-1]): # Exclude the newest toast
|
||||
new_position = self._calculate_toast_position(i)
|
||||
|
||||
# Animate to new position if needed
|
||||
current_geo = toast.geometry()
|
||||
if current_geo.y() != new_position.y():
|
||||
toast.slide_animation.stop()
|
||||
toast.slide_animation.setStartValue(current_geo)
|
||||
toast.slide_animation.setEndValue(new_position)
|
||||
toast.slide_animation.start()
|
||||
|
||||
def _on_toast_closed(self, toast: Toast):
|
||||
"""Handle when a toast is closed"""
|
||||
if toast in self.active_toasts:
|
||||
self.active_toasts.remove(toast)
|
||||
|
||||
# Reposition remaining toasts
|
||||
for i, remaining_toast in enumerate(self.active_toasts):
|
||||
new_position = self._calculate_toast_position(i)
|
||||
current_geo = remaining_toast.geometry()
|
||||
|
||||
if current_geo != new_position:
|
||||
remaining_toast.slide_animation.stop()
|
||||
remaining_toast.slide_animation.setStartValue(current_geo)
|
||||
remaining_toast.slide_animation.setEndValue(new_position)
|
||||
remaining_toast.slide_animation.start()
|
||||
|
||||
def clear_all_toasts(self):
|
||||
"""Dismiss all active toasts"""
|
||||
for toast in self.active_toasts.copy():
|
||||
toast.dismiss()
|
||||
|
||||
# Convenience methods for different toast types
|
||||
def success(self, message: str, duration: int = 4000):
|
||||
"""Show a success toast"""
|
||||
self.show_toast(message, ToastType.SUCCESS, duration)
|
||||
|
||||
def error(self, message: str, duration: int = 6000):
|
||||
"""Show an error toast (longer duration)"""
|
||||
self.show_toast(message, ToastType.ERROR, duration)
|
||||
|
||||
def warning(self, message: str, duration: int = 5000):
|
||||
"""Show a warning toast"""
|
||||
self.show_toast(message, ToastType.WARNING, duration)
|
||||
|
||||
def info(self, message: str, duration: int = 4000):
|
||||
"""Show an info toast"""
|
||||
self.show_toast(message, ToastType.INFO, duration)
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QFrame, QScrollArea, QWidget)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("version_info_modal")
|
||||
|
||||
class VersionInfoModal(QDialog):
|
||||
"""Modal displaying recent changes and version information"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("What's New in SoulSync v1.0")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(600, 500)
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
self.setStyleSheet("""
|
||||
VersionInfoModal {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header
|
||||
header = self.create_header()
|
||||
layout.addWidget(header)
|
||||
|
||||
# Content area with scroll
|
||||
content_area = self.create_content_area()
|
||||
layout.addWidget(content_area)
|
||||
|
||||
# Footer with close button
|
||||
footer = self.create_footer()
|
||||
layout.addWidget(footer)
|
||||
|
||||
def create_header(self):
|
||||
header = QFrame()
|
||||
header.setFixedHeight(80)
|
||||
header.setStyleSheet("""
|
||||
QFrame {
|
||||
background: #1a1a1a;
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(header)
|
||||
layout.setContentsMargins(30, 20, 30, 15)
|
||||
layout.setSpacing(5)
|
||||
|
||||
# Title
|
||||
title = QLabel("What's New in SoulSync")
|
||||
title.setFont(QFont("SF Pro Display", 18, QFont.Weight.Bold))
|
||||
title.setStyleSheet("""
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.5px;
|
||||
font-weight: 700;
|
||||
""")
|
||||
|
||||
# Version subtitle
|
||||
version_subtitle = QLabel("Version 1.0 - Complete WebUI Rebuild")
|
||||
version_subtitle.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium))
|
||||
version_subtitle.setStyleSheet("""
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 0.1px;
|
||||
margin-top: 2px;
|
||||
""")
|
||||
|
||||
layout.addWidget(title)
|
||||
layout.addWidget(version_subtitle)
|
||||
|
||||
return header
|
||||
|
||||
def create_content_area(self):
|
||||
# Scroll area for content
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
scroll_area.setStyleSheet("""
|
||||
QScrollArea {
|
||||
border: none;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
background: #2a2a2a;
|
||||
width: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #555555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #666666;
|
||||
}
|
||||
""")
|
||||
|
||||
# Content widget
|
||||
content_widget = QWidget()
|
||||
content_layout = QVBoxLayout(content_widget)
|
||||
content_layout.setContentsMargins(30, 25, 30, 25)
|
||||
content_layout.setSpacing(25)
|
||||
|
||||
# WebUI Transformation
|
||||
webui_section = self.create_feature_section(
|
||||
"Complete WebUI Transformation",
|
||||
"SoulSync has been completely rebuilt from the ground up as a modern web application, moving from desktop GUI to web-based interface",
|
||||
[
|
||||
"• Full transition from PyQt6 desktop application to responsive web interface",
|
||||
"• Modern HTML5, CSS3, and JavaScript implementation with premium glassmorphic design",
|
||||
"• Real-time updates and live status monitoring through WebSocket connections",
|
||||
"• Cross-platform compatibility - access from any device with a web browser",
|
||||
"• Mobile-responsive design optimized for tablets and smartphones",
|
||||
"• Dark theme with sophisticated visual effects and smooth animations",
|
||||
"• RESTful API architecture enabling future third-party integrations"
|
||||
],
|
||||
"Access SoulSync through your web browser at localhost:8888 - no desktop installation required!"
|
||||
)
|
||||
content_layout.addWidget(webui_section)
|
||||
|
||||
# Docker Support
|
||||
docker_section = self.create_feature_section(
|
||||
"Docker Container Support",
|
||||
"Complete containerization with Docker for easy deployment and scalability",
|
||||
[
|
||||
"• Pre-built Docker images available for instant deployment",
|
||||
"• Multi-architecture support (AMD64, ARM64) for various server platforms",
|
||||
"• Volume mounting for persistent configuration and downloads",
|
||||
"• Environment variable configuration for easy customization",
|
||||
"• Docker Compose templates for simplified multi-container setups",
|
||||
"• Automatic health checks and restart policies for reliability",
|
||||
"• Lightweight Alpine Linux base for minimal resource usage"
|
||||
]
|
||||
)
|
||||
content_layout.addWidget(docker_section)
|
||||
|
||||
# Enhanced Music Management
|
||||
music_section = self.create_feature_section(
|
||||
"Enhanced Music Management",
|
||||
"All beloved features preserved and enhanced with new web-based capabilities",
|
||||
[
|
||||
"• Complete Spotify, Tidal, and YouTube Music playlist synchronization",
|
||||
"• Advanced Soulseek integration with real-time download management",
|
||||
"• Intelligent music matching engine with improved accuracy",
|
||||
"• Plex and Jellyfin server integration with automatic library updates",
|
||||
"• Artist watchlist with automatic new release detection",
|
||||
"• Comprehensive metadata enhancement with high-quality album artwork",
|
||||
"• Real-time download progress with detailed logging and status updates"
|
||||
]
|
||||
)
|
||||
content_layout.addWidget(music_section)
|
||||
|
||||
# Performance & Reliability
|
||||
performance_section = self.create_feature_section(
|
||||
"Performance & Reliability",
|
||||
"Significant improvements in speed, stability, and resource efficiency",
|
||||
[
|
||||
"• Asynchronous processing for improved responsiveness",
|
||||
"• Multi-threaded download management with concurrent processing",
|
||||
"• Optimized database operations with connection pooling",
|
||||
"• Intelligent caching system for faster API responses",
|
||||
"• Robust error handling with automatic retry mechanisms",
|
||||
"• Memory-efficient architecture suitable for long-running deployments",
|
||||
"• Comprehensive logging system for easy troubleshooting"
|
||||
]
|
||||
)
|
||||
content_layout.addWidget(performance_section)
|
||||
|
||||
scroll_area.setWidget(content_widget)
|
||||
return scroll_area
|
||||
|
||||
def create_feature_section(self, title, description, features, usage_note=None):
|
||||
section = QFrame()
|
||||
section.setStyleSheet("""
|
||||
QFrame {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 3px solid rgba(29, 185, 84, 0.4);
|
||||
border-radius: 0px;
|
||||
padding: 0px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(section)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# Section title
|
||||
title_label = QLabel(title)
|
||||
title_label.setFont(QFont("SF Pro Text", 14, QFont.Weight.Bold))
|
||||
title_label.setStyleSheet("""
|
||||
color: #1ed760;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 3px;
|
||||
""")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Description
|
||||
desc_label = QLabel(description)
|
||||
desc_label.setFont(QFont("SF Pro Text", 11))
|
||||
desc_label.setStyleSheet("""
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
""")
|
||||
desc_label.setWordWrap(True)
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Features list
|
||||
for feature in features:
|
||||
feature_label = QLabel(feature)
|
||||
feature_label.setFont(QFont("SF Pro Text", 10))
|
||||
feature_label.setStyleSheet("""
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.5;
|
||||
padding-left: 8px;
|
||||
margin: 2px 0px;
|
||||
""")
|
||||
feature_label.setWordWrap(True)
|
||||
layout.addWidget(feature_label)
|
||||
|
||||
# Usage note if provided
|
||||
if usage_note:
|
||||
usage_label = QLabel(f"{usage_note}")
|
||||
usage_label.setFont(QFont("SF Pro Text", 10))
|
||||
usage_label.setStyleSheet("""
|
||||
color: #1ed760;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px 0px;
|
||||
margin-top: 8px;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
""")
|
||||
usage_label.setWordWrap(True)
|
||||
layout.addWidget(usage_label)
|
||||
|
||||
return section
|
||||
|
||||
def create_footer(self):
|
||||
footer = QFrame()
|
||||
footer.setFixedHeight(65)
|
||||
footer.setStyleSheet("""
|
||||
QFrame {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QHBoxLayout(footer)
|
||||
layout.setContentsMargins(30, 15, 30, 15)
|
||||
|
||||
# Close button
|
||||
close_button = QPushButton("Close")
|
||||
close_button.setFixedSize(100, 35)
|
||||
close_button.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium))
|
||||
close_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: #1db954;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #1ed760;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #169c46;
|
||||
}
|
||||
""")
|
||||
close_button.clicked.connect(self.accept)
|
||||
|
||||
layout.addStretch()
|
||||
layout.addWidget(close_button)
|
||||
|
||||
return footer
|
||||
File diff suppressed because it is too large
Load diff
5507
ui/pages/artists.py
5507
ui/pages/artists.py
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
11364
ui/pages/downloads.py
11364
ui/pages/downloads.py
File diff suppressed because it is too large
Load diff
3487
ui/pages/settings.py
3487
ui/pages/settings.py
File diff suppressed because it is too large
Load diff
9920
ui/pages/sync.py
9920
ui/pages/sync.py
File diff suppressed because it is too large
Load diff
1367
ui/sidebar.py
1367
ui/sidebar.py
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue