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:
Antti Kettunen 2026-04-17 19:51:14 +03:00
parent 2429d87dbe
commit a17e1030d3
17 changed files with 0 additions and 38990 deletions

View file

@ -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
View file

@ -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()

View file

@ -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

View file

@ -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;")

View file

@ -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)

View file

@ -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

View file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff