Merge pull request #311 from kettui/feat/remove-desktop-app
Remove desktop app, clean up test files, remove unused dependencies
This commit is contained in:
commit
0cf5cbe9cd
32 changed files with 77 additions and 42836 deletions
|
|
@ -15,9 +15,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
COPY requirements-webui.txt .
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements-webui.txt
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Stage 2: Runtime — only runtime dependencies, no build tools
|
||||
FROM python:3.11-slim
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ PUID/PGID are exposed in the template — set them to match your Unraid permissi
|
|||
```bash
|
||||
git clone https://github.com/Nezreka/SoulSync
|
||||
cd SoulSync
|
||||
pip install -r requirements-webui.txt
|
||||
pip install -r requirements.txt
|
||||
python web_server.py
|
||||
# Open http://localhost:8008
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,35 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Conditional PyQt6 import for backward compatibility with GUI version
|
||||
try:
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
QT_AVAILABLE = True
|
||||
except ImportError:
|
||||
QT_AVAILABLE = False
|
||||
# Define dummy classes for headless operation
|
||||
class QThread:
|
||||
def __init__(self):
|
||||
self.callbacks = {}
|
||||
def start(self):
|
||||
import threading
|
||||
self.thread = threading.Thread(target=self.run)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
def wait(self):
|
||||
if hasattr(self, 'thread'):
|
||||
self.thread.join()
|
||||
def emit_signal(self, signal_name, *args):
|
||||
if signal_name in self.callbacks:
|
||||
for callback in self.callbacks[signal_name]:
|
||||
try:
|
||||
callback(*args)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in callback for {signal_name}: {e}")
|
||||
def connect_signal(self, signal_name, callback):
|
||||
if signal_name not in self.callbacks:
|
||||
self.callbacks[signal_name] = []
|
||||
self.callbacks[signal_name].append(callback)
|
||||
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Optional, List, Callable
|
||||
|
|
@ -42,32 +12,19 @@ from config.settings import config_manager
|
|||
|
||||
logger = get_logger("database_update_worker")
|
||||
|
||||
class DatabaseUpdateWorker(QThread):
|
||||
"""Worker thread for updating SoulSync database with media server library data (Plex or Jellyfin)"""
|
||||
|
||||
# Qt signals (only available when PyQt6 is installed)
|
||||
if QT_AVAILABLE:
|
||||
progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage
|
||||
artist_processed = pyqtSignal(str, bool, str, int, int) # artist_name, success, details, albums_count, tracks_count
|
||||
finished = pyqtSignal(int, int, int, int, int) # total_artists, total_albums, total_tracks, successful, failed
|
||||
error = pyqtSignal(str) # error_message
|
||||
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
|
||||
class DatabaseUpdateWorker:
|
||||
"""Worker for updating SoulSync database with media server library data."""
|
||||
|
||||
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex", force_sequential: bool = False):
|
||||
super().__init__()
|
||||
|
||||
# Force sequential processing for web server mode to avoid threading issues
|
||||
self.force_sequential = force_sequential
|
||||
|
||||
# Initialize signal callbacks for headless mode
|
||||
if not QT_AVAILABLE:
|
||||
self.callbacks = {
|
||||
'progress_updated': [],
|
||||
'artist_processed': [],
|
||||
'finished': [],
|
||||
'error': [],
|
||||
'phase_changed': []
|
||||
}
|
||||
self.callbacks = {
|
||||
'progress_updated': [],
|
||||
'artist_processed': [],
|
||||
'finished': [],
|
||||
'error': [],
|
||||
'phase_changed': [],
|
||||
}
|
||||
|
||||
# Support both old plex_client parameter and new media_client parameter for backward compatibility
|
||||
if hasattr(media_client, '__class__') and 'plex' in media_client.__class__.__name__.lower():
|
||||
|
|
@ -116,19 +73,16 @@ class DatabaseUpdateWorker(QThread):
|
|||
self.database: Optional[MusicDatabase] = None
|
||||
|
||||
def _emit_signal(self, signal_name: str, *args):
|
||||
"""Emit a signal in both Qt and headless modes"""
|
||||
if QT_AVAILABLE and hasattr(self, signal_name):
|
||||
# Qt mode - use actual signal
|
||||
getattr(self, signal_name).emit(*args)
|
||||
elif not QT_AVAILABLE:
|
||||
# Headless mode - use callback system
|
||||
self.emit_signal(signal_name, *args)
|
||||
"""Emit a signal through the callback registry."""
|
||||
for callback in self.callbacks.get(signal_name, []):
|
||||
try:
|
||||
callback(*args)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in callback for {signal_name}: {e}")
|
||||
|
||||
def connect_callback(self, signal_name: str, callback: Callable):
|
||||
"""Connect a callback for headless mode"""
|
||||
if not QT_AVAILABLE:
|
||||
self.connect_signal(signal_name, callback)
|
||||
# In Qt mode, use the normal signal.connect() method
|
||||
"""Connect a callback for progress notifications."""
|
||||
self.callbacks.setdefault(signal_name, []).append(callback)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the database update process"""
|
||||
|
|
@ -1261,8 +1215,8 @@ class DatabaseUpdateWorker(QThread):
|
|||
logger.error(f"Error processing artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
||||
return (getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
|
||||
|
||||
# Process artists - use sequential processing in web server mode to avoid threading issues
|
||||
if not QT_AVAILABLE or self.force_sequential:
|
||||
# Process artists sequentially when requested (the web server uses this path).
|
||||
if self.force_sequential:
|
||||
# Sequential processing for web server mode
|
||||
for i, artist in enumerate(artists):
|
||||
if self.should_stop:
|
||||
|
|
@ -1277,7 +1231,7 @@ class DatabaseUpdateWorker(QThread):
|
|||
# Emit progress signal
|
||||
self._emit_signal('artist_processed', artist_name, success, details, album_count, track_count)
|
||||
else:
|
||||
# Process artists in parallel using ThreadPoolExecutor (Qt mode only)
|
||||
# Parallel processing for local/manual runs
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
# Submit all tasks
|
||||
future_to_artist = {executor.submit(process_single_artist, artist): artist
|
||||
|
|
@ -1409,71 +1363,3 @@ class DatabaseUpdateWorker(QThread):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in run_with_callback: {e}")
|
||||
|
||||
class DatabaseStatsWorker(QThread):
|
||||
"""Simple worker for getting database statistics without blocking UI"""
|
||||
|
||||
# Qt signals (only available when PyQt6 is installed)
|
||||
if QT_AVAILABLE:
|
||||
stats_updated = pyqtSignal(dict) # Database statistics
|
||||
|
||||
def __init__(self, database_path: str = "database/music_library.db"):
|
||||
super().__init__()
|
||||
self.database_path = database_path
|
||||
self.should_stop = False
|
||||
|
||||
# Initialize signal callbacks for headless mode
|
||||
if not QT_AVAILABLE:
|
||||
self.callbacks = {
|
||||
'stats_updated': []
|
||||
}
|
||||
|
||||
def stop(self):
|
||||
"""Stop the worker"""
|
||||
self.should_stop = True
|
||||
|
||||
def _emit_signal(self, signal_name: str, *args):
|
||||
"""Emit a signal in both Qt and headless modes"""
|
||||
if QT_AVAILABLE and hasattr(self, signal_name):
|
||||
# Qt mode - use actual signal
|
||||
getattr(self, signal_name).emit(*args)
|
||||
elif not QT_AVAILABLE:
|
||||
# Headless mode - use callback system
|
||||
self.emit_signal(signal_name, *args)
|
||||
|
||||
def connect_callback(self, signal_name: str, callback: Callable):
|
||||
"""Connect a callback for headless mode"""
|
||||
if not QT_AVAILABLE:
|
||||
self.connect_signal(signal_name, callback)
|
||||
# In Qt mode, use the normal signal.connect() method
|
||||
|
||||
def run(self):
|
||||
"""Get database statistics and full info including last refresh"""
|
||||
try:
|
||||
if self.should_stop:
|
||||
return
|
||||
|
||||
database = get_database(self.database_path)
|
||||
if self.should_stop:
|
||||
return
|
||||
|
||||
# Get database info for active server (server-aware statistics)
|
||||
info = database.get_database_info_for_server()
|
||||
if not self.should_stop:
|
||||
self._emit_signal('stats_updated', info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database stats: {e}")
|
||||
if not self.should_stop:
|
||||
# Import here to avoid circular imports
|
||||
from config.settings import config_manager
|
||||
active_server = config_manager.get_active_media_server()
|
||||
|
||||
self._emit_signal('stats_updated', {
|
||||
'artists': 0,
|
||||
'albums': 0,
|
||||
'tracks': 0,
|
||||
'database_size_mb': 0.0,
|
||||
'last_update': None,
|
||||
'last_full_refresh': None,
|
||||
'server_source': active_server
|
||||
})
|
||||
|
|
@ -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")
|
||||
|
|
@ -1,342 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import threading
|
||||
import time
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("plex_scan_manager")
|
||||
|
||||
class PlexScanManager:
|
||||
"""
|
||||
Smart Plex library scan manager with debouncing and scan-aware follow-up logic.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
def __init__(self, plex_client, delay_seconds: int = 60):
|
||||
"""
|
||||
Initialize the scan manager.
|
||||
|
||||
Args:
|
||||
plex_client: PlexClient instance with trigger_library_scan method
|
||||
delay_seconds: Debounce delay in seconds (default 60s)
|
||||
"""
|
||||
self.plex_client = plex_client
|
||||
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"PlexScanManager initialized with {delay_seconds}s debounce delay")
|
||||
|
||||
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: Plex scan requested - reason: {reason}")
|
||||
with self._lock:
|
||||
if self._shutting_down:
|
||||
logger.debug("Plex scan request ignored during shutdown")
|
||||
return
|
||||
if self._scan_in_progress:
|
||||
# Plex is currently scanning - mark that we need another scan later
|
||||
self._downloads_during_scan = True
|
||||
logger.info(f"Plex 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"Plex 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 Plex library scan"""
|
||||
with self._lock:
|
||||
if self._shutting_down:
|
||||
logger.debug("Plex 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()
|
||||
|
||||
logger.info("Starting Plex library scan...")
|
||||
|
||||
try:
|
||||
success = self.plex_client.trigger_library_scan()
|
||||
|
||||
if success:
|
||||
logger.info("Plex library scan initiated successfully")
|
||||
# Start new periodic update system instead of completion detection
|
||||
self._start_periodic_updates()
|
||||
else:
|
||||
logger.error("Failed to initiate Plex library scan")
|
||||
self._reset_scan_state()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Exception during Plex library scan: {e}")
|
||||
self._reset_scan_state()
|
||||
|
||||
def _start_periodic_updates(self):
|
||||
"""Start periodic database updates while Plex 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"Plex scan timeout reached ({self._max_scan_time}s), stopping periodic updates")
|
||||
self._stop_periodic_updates()
|
||||
return
|
||||
|
||||
# Check if Plex is still scanning
|
||||
is_scanning = self.plex_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 - Plex scanning: {is_scanning}")
|
||||
|
||||
if is_scanning:
|
||||
# Still scanning - trigger database update and continue periodic updates
|
||||
logger.info("Plex 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("Plex 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 _poll_scan_status(self):
|
||||
"""Poll Plex to check if library scan is still running"""
|
||||
try:
|
||||
with self._lock:
|
||||
if not self._scan_in_progress:
|
||||
logger.debug("Scan no longer in progress, stopping polling")
|
||||
return
|
||||
|
||||
# Check for timeout
|
||||
if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time:
|
||||
logger.warning(f"Plex scan timeout reached ({self._max_scan_time}s), assuming completion")
|
||||
self._scan_completed()
|
||||
return
|
||||
|
||||
# Check if Plex is still scanning
|
||||
is_scanning = self.plex_client.is_library_scanning("Music")
|
||||
logger.info(f"DEBUG: Plex scan status check - is_scanning: {is_scanning}")
|
||||
|
||||
if is_scanning:
|
||||
# Still scanning, poll again in 30 seconds
|
||||
logger.info("DEBUG: Plex library still scanning, will check again in 30 seconds")
|
||||
timer = threading.Timer(30, self._poll_scan_status)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
else:
|
||||
# Scan completed!
|
||||
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
|
||||
logger.info(f"Plex library scan detected as completed (took {elapsed_time:.1f} seconds)")
|
||||
self._scan_completed()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error polling scan status: {e}")
|
||||
# Fallback to assuming completion after error
|
||||
self._scan_completed()
|
||||
|
||||
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("Plex 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("PlexScanManager 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,7 +1,7 @@
|
|||
# SoulSync development requirements
|
||||
# Runtime web dependencies + test runner
|
||||
|
||||
-r requirements-webui.txt
|
||||
-r requirements.txt
|
||||
|
||||
# Test runner
|
||||
pytest>=9.0.0
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
# SoulSync WebUI Requirements
|
||||
# Docker-compatible requirements without PyQt6 dependencies
|
||||
|
||||
# Core web framework
|
||||
Flask>=3.0.0
|
||||
Flask-Limiter>=3.5.0
|
||||
|
||||
# Music service APIs
|
||||
spotipy>=2.23.0
|
||||
PlexAPI>=4.17.0
|
||||
|
||||
# HTTP and async support
|
||||
requests>=2.31.0
|
||||
aiohttp>=3.9.0
|
||||
|
||||
# Configuration management
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Security and encryption
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Media metadata handling
|
||||
mutagen>=1.47.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
# Text processing
|
||||
unidecode>=1.3.8
|
||||
beautifulsoup4>=4.12.0
|
||||
|
||||
# System monitoring
|
||||
psutil>=6.0.0
|
||||
|
||||
# YouTube support
|
||||
yt-dlp>=2024.12.13
|
||||
|
||||
# Lyrics support
|
||||
lrclibapi>=0.3.1
|
||||
|
||||
# Optional: MQTT support (for future features)
|
||||
asyncio-mqtt>=0.16.0
|
||||
|
||||
# Audio fingerprinting for download verification
|
||||
pyacoustid>=1.3.0
|
||||
|
||||
# WebSocket client for Hydrabase connection
|
||||
websocket-client>=1.7.0
|
||||
|
||||
# Tidal download support
|
||||
tidalapi>=0.7.6
|
||||
|
||||
# WebSocket server for real-time UI updates
|
||||
flask-socketio>=5.3.0
|
||||
|
|
@ -1,16 +1,46 @@
|
|||
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
|
||||
# SoulSync requirements
|
||||
# Web application dependencies only
|
||||
|
||||
# Core web framework
|
||||
Flask>=3.0.0
|
||||
Flask-Limiter>=3.5.0
|
||||
|
||||
# Music service APIs
|
||||
spotipy>=2.23.0
|
||||
PlexAPI>=4.17.0
|
||||
|
||||
# HTTP and async support
|
||||
requests>=2.31.0
|
||||
aiohttp>=3.9.0
|
||||
|
||||
# Security and encryption
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Media metadata handling
|
||||
mutagen>=1.47.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
# Text processing
|
||||
unidecode>=1.3.8
|
||||
beautifulsoup4>=4.12.0
|
||||
|
||||
# System monitoring
|
||||
psutil>=6.0.0
|
||||
|
||||
# YouTube support
|
||||
yt-dlp>=2024.12.13
|
||||
|
||||
# Lyrics support
|
||||
lrclibapi>=0.3.1
|
||||
pyacoustid>=1.3.0
|
||||
|
||||
# Audio fingerprinting for download verification
|
||||
pyacoustid>=1.3.0
|
||||
|
||||
# WebSocket client for Hydrabase connection
|
||||
websocket-client>=1.7.0
|
||||
|
||||
# Tidal download support
|
||||
tidalapi>=0.7.6
|
||||
|
||||
# WebSocket server for real-time UI updates
|
||||
flask-socketio>=5.3.0
|
||||
|
|
|
|||
295
test_acoustid.py
295
test_acoustid.py
|
|
@ -1,295 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
AcoustID Integration Test Script
|
||||
|
||||
Run this script to test the AcoustID verification system before using it in production.
|
||||
It will check:
|
||||
1. fpcalc binary availability
|
||||
2. API key validation
|
||||
3. Fingerprint generation (if audio file provided)
|
||||
4. Full verification flow (if audio file and expected track info provided)
|
||||
|
||||
Usage:
|
||||
python test_acoustid.py # Basic tests
|
||||
python test_acoustid.py path/to/audio.mp3 # Test with audio file
|
||||
python test_acoustid.py path/to/audio.mp3 "Song Title" "Artist Name" # Full test
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import io
|
||||
|
||||
# Fix Windows encoding issues
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def print_header(text):
|
||||
print("\n" + "=" * 60)
|
||||
print(f" {text}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def print_result(success, message):
|
||||
icon = "[PASS]" if success else "[FAIL]"
|
||||
print(f" {icon} {message}")
|
||||
|
||||
|
||||
def test_chromaprint():
|
||||
"""Test if chromaprint/fpcalc is available for fingerprinting."""
|
||||
print_header("Testing fingerprint backend availability")
|
||||
|
||||
from core.acoustid_client import CHROMAPRINT_AVAILABLE, ACOUSTID_AVAILABLE, FPCALC_PATH
|
||||
|
||||
if not ACOUSTID_AVAILABLE:
|
||||
print_result(False, "pyacoustid library not installed!")
|
||||
print("\n To install:")
|
||||
print(" pip install pyacoustid")
|
||||
return False
|
||||
|
||||
if CHROMAPRINT_AVAILABLE and FPCALC_PATH:
|
||||
print_result(True, f"fpcalc ready: {FPCALC_PATH}")
|
||||
return True
|
||||
|
||||
if CHROMAPRINT_AVAILABLE:
|
||||
print_result(True, "Fingerprint backend available")
|
||||
return True
|
||||
|
||||
print_result(False, "No fingerprint backend available!")
|
||||
print("\n fpcalc will be auto-downloaded on first use.")
|
||||
print(" Or manually install:")
|
||||
print(" - Windows: Auto-download supported")
|
||||
print(" - macOS: brew install chromaprint")
|
||||
print(" - Linux: apt install libchromaprint-tools")
|
||||
return False
|
||||
|
||||
|
||||
def test_api_key():
|
||||
"""Test if AcoustID API key is configured and valid."""
|
||||
print_header("Testing AcoustID API key")
|
||||
|
||||
from core.acoustid_client import AcoustIDClient
|
||||
from config.settings import config_manager
|
||||
|
||||
api_key = config_manager.get('acoustid.api_key', '')
|
||||
|
||||
if not api_key:
|
||||
print_result(False, "No API key configured in settings")
|
||||
print("\n To configure:")
|
||||
print(" 1. Get a free API key from https://acoustid.org/new-application")
|
||||
print(" 2. Add it in Settings > AcoustID section")
|
||||
return False
|
||||
|
||||
print(f" API key found: {api_key[:8]}...{api_key[-4:]}")
|
||||
|
||||
client = AcoustIDClient()
|
||||
success, message = client.test_api_key()
|
||||
|
||||
print_result(success, message)
|
||||
return success
|
||||
|
||||
|
||||
def test_enabled():
|
||||
"""Test if AcoustID verification is enabled."""
|
||||
print_header("Testing AcoustID enabled status")
|
||||
|
||||
from config.settings import config_manager
|
||||
|
||||
enabled = config_manager.get('acoustid.enabled', False)
|
||||
|
||||
if enabled:
|
||||
print_result(True, "AcoustID verification is ENABLED")
|
||||
else:
|
||||
print_result(False, "AcoustID verification is DISABLED")
|
||||
print("\n To enable:")
|
||||
print(" 1. Go to Settings > AcoustID section")
|
||||
print(" 2. Check 'Enable Download Verification'")
|
||||
|
||||
return enabled
|
||||
|
||||
|
||||
def test_availability():
|
||||
"""Test overall availability."""
|
||||
print_header("Testing overall availability")
|
||||
|
||||
from core.acoustid_client import AcoustIDClient
|
||||
|
||||
client = AcoustIDClient()
|
||||
available, reason = client.is_available()
|
||||
|
||||
print_result(available, reason)
|
||||
return available
|
||||
|
||||
|
||||
def test_fingerprint_and_lookup(audio_file):
|
||||
"""Test fingerprint generation and AcoustID lookup for an audio file."""
|
||||
print_header(f"Testing fingerprint and AcoustID lookup")
|
||||
print(f" File: {audio_file}")
|
||||
|
||||
if not os.path.isfile(audio_file):
|
||||
print_result(False, f"File not found: {audio_file}")
|
||||
return None
|
||||
|
||||
from core.acoustid_client import AcoustIDClient
|
||||
|
||||
client = AcoustIDClient()
|
||||
|
||||
available, reason = client.is_available()
|
||||
if not available:
|
||||
print_result(False, f"AcoustID not available: {reason}")
|
||||
return None
|
||||
|
||||
print(" Fingerprinting and looking up (this may take a moment)...")
|
||||
result = client.fingerprint_and_lookup(audio_file)
|
||||
|
||||
if result:
|
||||
recordings = result.get('recordings', [])
|
||||
score = result.get('best_score', 0)
|
||||
print_result(True, f"Found {len(recordings)} recording(s) (score: {score:.2f})")
|
||||
|
||||
for i, rec in enumerate(recordings[:5]): # Show first 5
|
||||
title = rec.get('title', '?')
|
||||
artist = rec.get('artist', '?')
|
||||
mbid = rec.get('mbid', '?')
|
||||
rec_score = rec.get('score', 0)
|
||||
print(f" {i+1}. \"{title}\" by {artist} (score: {rec_score:.2f})")
|
||||
print(f" https://musicbrainz.org/recording/{mbid}")
|
||||
|
||||
if len(recordings) > 5:
|
||||
print(f" ... and {len(recordings) - 5} more")
|
||||
|
||||
return result
|
||||
else:
|
||||
print_result(False, "Track not found in AcoustID database")
|
||||
print(" This may be a rare/new track not yet fingerprinted.")
|
||||
return None
|
||||
|
||||
|
||||
def test_musicbrainz_lookup(track_name, artist_name):
|
||||
"""Test MusicBrainz lookup for expected track."""
|
||||
print_header("Testing MusicBrainz lookup")
|
||||
print(f" Track: '{track_name}'")
|
||||
print(f" Artist: '{artist_name}'")
|
||||
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
from core.musicbrainz_service import MusicBrainzService
|
||||
|
||||
db = MusicDatabase()
|
||||
mb_service = MusicBrainzService(db)
|
||||
|
||||
print(" Searching MusicBrainz...")
|
||||
result = mb_service.match_recording(track_name, artist_name)
|
||||
|
||||
if result:
|
||||
mbid = result.get('mbid')
|
||||
confidence = result.get('confidence', 0)
|
||||
cached = result.get('cached', False)
|
||||
|
||||
print_result(True, f"Found match (confidence: {confidence}%)")
|
||||
print(f" MBID: {mbid}")
|
||||
print(f" https://musicbrainz.org/recording/{mbid}")
|
||||
print(f" Cached: {cached}")
|
||||
return result
|
||||
else:
|
||||
print_result(False, "No match found in MusicBrainz")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print_result(False, f"Error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def test_full_verification(audio_file, track_name, artist_name):
|
||||
"""Test the full verification flow."""
|
||||
print_header("Testing full verification flow")
|
||||
print(f" File: {audio_file}")
|
||||
print(f" Expected: '{track_name}' by '{artist_name}'")
|
||||
|
||||
from core.acoustid_verification import AcoustIDVerification, VerificationResult
|
||||
|
||||
verifier = AcoustIDVerification()
|
||||
|
||||
# Check availability first
|
||||
available, reason = verifier.quick_check_available()
|
||||
if not available:
|
||||
print_result(False, f"Verification not available: {reason}")
|
||||
return
|
||||
|
||||
print(" Running verification (this may take a moment)...")
|
||||
result, message = verifier.verify_audio_file(
|
||||
audio_file,
|
||||
track_name,
|
||||
artist_name
|
||||
)
|
||||
|
||||
if result == VerificationResult.PASS:
|
||||
print_result(True, f"VERIFICATION PASSED: {message}")
|
||||
elif result == VerificationResult.FAIL:
|
||||
print_result(False, f"VERIFICATION FAILED: {message}")
|
||||
elif result == VerificationResult.SKIP:
|
||||
print(f" [SKIP] Verification skipped: {message}")
|
||||
else:
|
||||
print(f" [????] Unknown result: {result.value} - {message}")
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "=" * 60)
|
||||
print(" ACOUSTID VERIFICATION SYSTEM TEST")
|
||||
print("=" * 60)
|
||||
|
||||
# Parse arguments
|
||||
audio_file = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
track_name = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
artist_name = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
# Run basic tests
|
||||
chromaprint_ok = test_chromaprint()
|
||||
api_key_ok = test_api_key()
|
||||
enabled_ok = test_enabled()
|
||||
available_ok = test_availability()
|
||||
|
||||
# Summary of basic tests
|
||||
print_header("Basic Tests Summary")
|
||||
print(f" Chromaprint: {'OK' if chromaprint_ok else 'MISSING'}")
|
||||
print(f" API key: {'OK' if api_key_ok else 'MISSING/INVALID'}")
|
||||
print(f" Enabled: {'YES' if enabled_ok else 'NO'}")
|
||||
print(f" Available: {'YES' if available_ok else 'NO'}")
|
||||
|
||||
if not audio_file:
|
||||
print("\n" + "-" * 60)
|
||||
print(" To test fingerprinting, provide an audio file:")
|
||||
print(" python test_acoustid.py path/to/audio.mp3")
|
||||
print("\n To test full verification flow:")
|
||||
print(" python test_acoustid.py path/to/audio.mp3 \"Song Title\" \"Artist\"")
|
||||
print("-" * 60)
|
||||
return
|
||||
|
||||
# Test with audio file (combined fingerprint + lookup)
|
||||
lookup_result = test_fingerprint_and_lookup(audio_file)
|
||||
|
||||
if track_name and artist_name:
|
||||
# Test MusicBrainz lookup
|
||||
mb_result = test_musicbrainz_lookup(track_name, artist_name)
|
||||
|
||||
# Test full verification
|
||||
if available_ok:
|
||||
test_full_verification(audio_file, track_name, artist_name)
|
||||
else:
|
||||
print("\n Skipping full verification test (not available)")
|
||||
|
||||
# Point to log file
|
||||
print("\n" + "-" * 60)
|
||||
log_path = Path(__file__).parent / "logs" / "acoustid.log"
|
||||
print(f" Detailed logs: {log_path}")
|
||||
print("-" * 60 + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1271
test_hifi_client.py
1271
test_hifi_client.py
File diff suppressed because it is too large
Load diff
|
|
@ -21,16 +21,6 @@ import time
|
|||
class TestSystemStats:
|
||||
"""dashboard:stats socket events match GET /api/system/stats."""
|
||||
|
||||
def test_stats_event_received(self, test_app, shared_state):
|
||||
"""Client receives a dashboard:stats event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_system_stats']
|
||||
socketio.emit('dashboard:stats', build())
|
||||
received = client.get_received()
|
||||
stats_events = [e for e in received if e['name'] == 'dashboard:stats']
|
||||
assert len(stats_events) >= 1
|
||||
|
||||
def test_stats_shape(self, test_app, shared_state):
|
||||
"""dashboard:stats event data has expected keys."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -70,16 +60,6 @@ class TestSystemStats:
|
|||
assert ws_data['uptime'] == http_data['uptime']
|
||||
assert ws_data['memory_usage'] == http_data['memory_usage']
|
||||
|
||||
def test_http_stats_still_works(self, flask_client):
|
||||
"""GET /api/system/stats returns 200 with expected keys."""
|
||||
resp = flask_client.get('/api/system/stats')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'active_downloads' in data
|
||||
assert 'finished_downloads' in data
|
||||
assert 'download_speed' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group B — Activity Feed
|
||||
# =========================================================================
|
||||
|
|
@ -87,16 +67,6 @@ class TestSystemStats:
|
|||
class TestActivityFeed:
|
||||
"""dashboard:activity socket events match GET /api/activity/feed."""
|
||||
|
||||
def test_activity_event_received(self, test_app, shared_state):
|
||||
"""Client receives a dashboard:activity event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_activity_feed_payload']
|
||||
socketio.emit('dashboard:activity', build())
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'dashboard:activity']
|
||||
assert len(events) >= 1
|
||||
|
||||
def test_activity_shape(self, test_app, shared_state):
|
||||
"""dashboard:activity event data has activities array."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -136,14 +106,6 @@ class TestActivityFeed:
|
|||
if ws_data['activities']:
|
||||
assert ws_data['activities'][0]['title'] == http_data['activities'][0]['title']
|
||||
|
||||
def test_http_activity_still_works(self, flask_client):
|
||||
"""GET /api/activity/feed returns 200 with expected structure."""
|
||||
resp = flask_client.get('/api/activity/feed')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'activities' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group C — Toasts (instant push)
|
||||
# =========================================================================
|
||||
|
|
@ -201,18 +163,6 @@ class TestToasts:
|
|||
assert 'show_toast' in data
|
||||
assert data['show_toast'] is True
|
||||
|
||||
def test_http_toasts_still_works(self, flask_client, shared_state):
|
||||
"""GET /api/activity/toasts returns 200 with expected structure."""
|
||||
# Add a toast-worthy activity first
|
||||
add_item = shared_state['add_activity_item']
|
||||
add_item('', 'Test', 'Sub', show_toast=True)
|
||||
|
||||
resp = flask_client.get('/api/activity/toasts')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'toasts' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group D — DB Stats
|
||||
# =========================================================================
|
||||
|
|
@ -220,16 +170,6 @@ class TestToasts:
|
|||
class TestDbStats:
|
||||
"""dashboard:db_stats socket events match GET /api/database/stats."""
|
||||
|
||||
def test_db_stats_event_received(self, test_app, shared_state):
|
||||
"""Client receives a dashboard:db_stats event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_db_stats']
|
||||
socketio.emit('dashboard:db_stats', build())
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'dashboard:db_stats']
|
||||
assert len(events) >= 1
|
||||
|
||||
def test_db_stats_shape(self, test_app, shared_state):
|
||||
"""dashboard:db_stats event data has expected keys."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -266,16 +206,6 @@ class TestDbStats:
|
|||
assert ws_data['database_size_mb'] == http_data['database_size_mb']
|
||||
assert ws_data['server_source'] == http_data['server_source']
|
||||
|
||||
def test_http_db_stats_still_works(self, flask_client):
|
||||
"""GET /api/database/stats returns 200 with expected keys."""
|
||||
resp = flask_client.get('/api/database/stats')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'artists' in data
|
||||
assert 'albums' in data
|
||||
assert 'tracks' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group E — Wishlist Count
|
||||
# =========================================================================
|
||||
|
|
@ -283,16 +213,6 @@ class TestDbStats:
|
|||
class TestWishlistCount:
|
||||
"""dashboard:wishlist_count socket events match GET /api/wishlist/count."""
|
||||
|
||||
def test_wishlist_count_received(self, test_app, shared_state):
|
||||
"""Client receives a dashboard:wishlist_count event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_wishlist_count_payload_ws']
|
||||
socketio.emit('dashboard:wishlist_count', build())
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'dashboard:wishlist_count']
|
||||
assert len(events) >= 1
|
||||
|
||||
def test_wishlist_count_shape(self, test_app, shared_state):
|
||||
"""dashboard:wishlist_count event data has count key."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -322,54 +242,6 @@ class TestWishlistCount:
|
|||
|
||||
assert ws_data['count'] == http_data['count']
|
||||
|
||||
def test_http_wishlist_count_still_works(self, flask_client):
|
||||
"""GET /api/wishlist/count returns 200 with count."""
|
||||
resp = flask_client.get('/api/wishlist/count')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'count' in data
|
||||
assert isinstance(data['count'], int)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group F — Backward Compatibility
|
||||
# =========================================================================
|
||||
|
||||
class TestBackwardCompat:
|
||||
"""HTTP endpoints work when no WebSocket is connected."""
|
||||
|
||||
def test_all_http_endpoints_work_without_socket(self, flask_client):
|
||||
"""All 5 Phase 2 HTTP endpoints work without any WebSocket connection."""
|
||||
# System stats
|
||||
resp = flask_client.get('/api/system/stats')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['active_downloads'] == 2
|
||||
|
||||
# Activity feed
|
||||
resp = flask_client.get('/api/activity/feed')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'activities' in data
|
||||
|
||||
# Toasts
|
||||
resp = flask_client.get('/api/activity/toasts')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'toasts' in data
|
||||
|
||||
# DB stats
|
||||
resp = flask_client.get('/api/database/stats')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['artists'] == 350
|
||||
|
||||
# Wishlist count
|
||||
resp = flask_client.get('/api/wishlist/count')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['count'] == 5
|
||||
|
||||
def test_multiple_clients_get_dashboard_updates(self, test_app, shared_state):
|
||||
"""Multiple WebSocket clients each receive dashboard broadcast events."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -39,19 +39,6 @@ ENDPOINTS = {
|
|||
class TestEnrichmentEventDelivery:
|
||||
"""enrichment:<worker> socket events are received by the client."""
|
||||
|
||||
@pytest.mark.parametrize('worker', WORKERS)
|
||||
def test_enrichment_event_received(self, test_app, shared_state, worker):
|
||||
"""Client receives an enrichment:<worker> event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_enrichment_status']
|
||||
|
||||
socketio.emit(f'enrichment:{worker}', build(worker))
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == f'enrichment:{worker}']
|
||||
assert len(events) >= 1
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group B — Data Shape (parameterized)
|
||||
# =========================================================================
|
||||
|
|
@ -160,38 +147,11 @@ class TestEnrichmentHttpParity:
|
|||
|
||||
|
||||
# =========================================================================
|
||||
# Group D — HTTP Still Works (parameterized)
|
||||
# =========================================================================
|
||||
|
||||
class TestEnrichmentHttpStillWorks:
|
||||
"""HTTP endpoints return 200 with expected structure."""
|
||||
|
||||
@pytest.mark.parametrize('worker', WORKERS)
|
||||
def test_http_enrichment_still_works(self, flask_client, worker):
|
||||
"""GET /api/<worker>/status returns 200."""
|
||||
endpoint = ENDPOINTS[worker]
|
||||
resp = flask_client.get(endpoint)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'running' in data
|
||||
assert 'paused' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group E — Backward Compatibility
|
||||
# Group D — Backward Compatibility
|
||||
# =========================================================================
|
||||
|
||||
class TestEnrichmentBackwardCompat:
|
||||
"""HTTP endpoints work when no WebSocket is connected."""
|
||||
|
||||
def test_all_http_endpoints_work_without_socket(self, flask_client):
|
||||
"""All 7 enrichment HTTP endpoints work without any WebSocket connection."""
|
||||
for worker in WORKERS:
|
||||
endpoint = ENDPOINTS[worker]
|
||||
resp = flask_client.get(endpoint)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['running'] is True
|
||||
"""WebSocket clients still receive broadcast enrichment updates."""
|
||||
|
||||
def test_multiple_clients_get_enrichment_updates(self, test_app, shared_state):
|
||||
"""Multiple WebSocket clients each receive enrichment events."""
|
||||
|
|
@ -29,28 +29,6 @@ SYNC_PLAYLIST_IDS = ['test-playlist-1']
|
|||
|
||||
class TestSyncEventDelivery:
|
||||
"""sync:progress socket events are received by subscribed clients."""
|
||||
|
||||
def test_sync_event_received(self, test_app, shared_state):
|
||||
"""Client subscribes and receives sync:progress event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_sync_status']
|
||||
|
||||
# Subscribe to sync room
|
||||
client.emit('sync:subscribe', {'playlist_ids': ['test-playlist-1']})
|
||||
|
||||
# Server emits to the room
|
||||
socketio.emit('sync:progress', {
|
||||
'playlist_id': 'test-playlist-1',
|
||||
**build('test-playlist-1')
|
||||
}, room='sync:test-playlist-1')
|
||||
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'sync:progress']
|
||||
assert len(events) >= 1
|
||||
|
||||
client.disconnect()
|
||||
|
||||
def test_sync_only_subscribed(self, test_app, shared_state):
|
||||
"""Unsubscribed client does NOT receive sync:progress events."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -155,30 +133,6 @@ class TestSyncDataShape:
|
|||
|
||||
class TestDiscoveryEventDelivery:
|
||||
"""discovery:progress socket events are received by subscribed clients."""
|
||||
|
||||
@pytest.mark.parametrize('platform,pid', [
|
||||
('tidal', 'test-tidal-1'),
|
||||
('youtube', 'test-yt-hash'),
|
||||
])
|
||||
def test_discovery_event_received(self, test_app, shared_state, platform, pid):
|
||||
"""Client subscribes and receives discovery:progress event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_discovery_status']
|
||||
|
||||
client.emit('discovery:subscribe', {'ids': [pid]})
|
||||
|
||||
payload = build(platform, pid)
|
||||
payload['platform'] = platform
|
||||
payload['id'] = pid
|
||||
socketio.emit('discovery:progress', payload, room=f'discovery:{pid}')
|
||||
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'discovery:progress']
|
||||
assert len(events) >= 1
|
||||
|
||||
client.disconnect()
|
||||
|
||||
def test_discovery_only_subscribed(self, test_app, shared_state):
|
||||
"""Unsubscribed client does NOT receive discovery:progress events."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -281,24 +235,6 @@ class TestDiscoveryDataShape:
|
|||
|
||||
class TestScanEventDelivery:
|
||||
"""Broadcast scan events are received by all connected clients."""
|
||||
|
||||
def test_watchlist_scan_received(self, test_app, shared_state):
|
||||
"""All clients receive scan:watchlist event."""
|
||||
app, socketio = test_app
|
||||
client1 = socketio.test_client(app)
|
||||
client2 = socketio.test_client(app)
|
||||
build = shared_state['build_watchlist_scan_status']
|
||||
|
||||
socketio.emit('scan:watchlist', build())
|
||||
|
||||
for client in [client1, client2]:
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'scan:watchlist']
|
||||
assert len(events) >= 1
|
||||
|
||||
client1.disconnect()
|
||||
client2.disconnect()
|
||||
|
||||
def test_watchlist_scan_shape(self, test_app, shared_state):
|
||||
"""scan:watchlist data has success, status, current_artist_name."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -321,23 +257,6 @@ class TestScanEventDelivery:
|
|||
|
||||
client.disconnect()
|
||||
|
||||
def test_media_scan_received(self, test_app, shared_state):
|
||||
"""All clients receive scan:media event."""
|
||||
app, socketio = test_app
|
||||
client1 = socketio.test_client(app)
|
||||
client2 = socketio.test_client(app)
|
||||
build = shared_state['build_media_scan_status']
|
||||
|
||||
socketio.emit('scan:media', build())
|
||||
|
||||
for client in [client1, client2]:
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'scan:media']
|
||||
assert len(events) >= 1
|
||||
|
||||
client1.disconnect()
|
||||
client2.disconnect()
|
||||
|
||||
def test_media_scan_shape(self, test_app, shared_state):
|
||||
"""scan:media data has success and status with is_scanning."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -491,33 +410,11 @@ class TestScanHttpParity:
|
|||
|
||||
class TestHttpStillWorks:
|
||||
"""HTTP endpoints return 200 with expected structure."""
|
||||
|
||||
def test_sync_http_works(self, flask_client):
|
||||
"""GET /api/sync/status/:id returns 200."""
|
||||
resp = flask_client.get('/api/sync/status/test-playlist-1')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'status' in data
|
||||
assert 'progress' in data
|
||||
|
||||
def test_sync_http_404_unknown(self, flask_client):
|
||||
"""GET /api/sync/status/:id returns 404 for unknown playlist."""
|
||||
resp = flask_client.get('/api/sync/status/nonexistent')
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.parametrize('platform,endpoint', [
|
||||
('tidal', '/api/tidal/discovery/status/test-tidal-1'),
|
||||
('youtube', '/api/youtube/discovery/status/test-yt-hash'),
|
||||
])
|
||||
def test_discovery_http_works(self, flask_client, platform, endpoint):
|
||||
"""GET /api/<platform>/discovery/status/:id returns 200."""
|
||||
resp = flask_client.get(endpoint)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'phase' in data
|
||||
assert 'status' in data
|
||||
assert 'results' in data
|
||||
|
||||
def test_discovery_http_not_found(self, flask_client):
|
||||
"""GET /api/tidal/discovery/status/:id returns error for unknown ID."""
|
||||
resp = flask_client.get('/api/tidal/discovery/status/nonexistent')
|
||||
|
|
@ -525,42 +422,12 @@ class TestHttpStillWorks:
|
|||
data = resp.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
def test_watchlist_scan_http_works(self, flask_client):
|
||||
"""GET /api/watchlist/scan/status returns 200."""
|
||||
resp = flask_client.get('/api/watchlist/scan/status')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['success'] is True
|
||||
assert 'status' in data
|
||||
|
||||
def test_media_scan_http_works(self, flask_client):
|
||||
"""GET /api/scan/status returns 200."""
|
||||
resp = flask_client.get('/api/scan/status')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['success'] is True
|
||||
assert 'status' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group H — Backward Compatibility
|
||||
# Group H — Broadcast Updates
|
||||
# =========================================================================
|
||||
|
||||
class TestBackwardCompat:
|
||||
"""HTTP endpoints work when no WebSocket is connected."""
|
||||
|
||||
def test_all_http_endpoints_work_without_socket(self, flask_client):
|
||||
"""All Phase 5 HTTP endpoints work without any WebSocket connection."""
|
||||
endpoints = [
|
||||
'/api/sync/status/test-playlist-1',
|
||||
'/api/tidal/discovery/status/test-tidal-1',
|
||||
'/api/youtube/discovery/status/test-yt-hash',
|
||||
'/api/watchlist/scan/status',
|
||||
'/api/scan/status',
|
||||
]
|
||||
for endpoint in endpoints:
|
||||
resp = flask_client.get(endpoint)
|
||||
assert resp.status_code == 200
|
||||
"""Broadcast scan events still reach multiple clients."""
|
||||
|
||||
def test_multiple_clients_get_scan_updates(self, test_app, shared_state):
|
||||
"""Multiple WebSocket clients each receive scan events."""
|
||||
|
|
@ -668,29 +535,6 @@ PLATFORM_SYNC_IDS = [
|
|||
|
||||
class TestPlatformSyncEventDelivery:
|
||||
"""Platform sync pollers receive sync:progress via WS rooms."""
|
||||
|
||||
@pytest.mark.parametrize('platform,sync_id', PLATFORM_SYNC_IDS)
|
||||
def test_platform_sync_event_received(self, test_app, shared_state, platform, sync_id):
|
||||
"""Client subscribes to platform sync_playlist_id and receives sync:progress."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_sync_status']
|
||||
|
||||
client.emit('sync:subscribe', {'playlist_ids': [sync_id]})
|
||||
|
||||
socketio.emit('sync:progress', {
|
||||
'playlist_id': sync_id, **build(sync_id)
|
||||
}, room=f'sync:{sync_id}')
|
||||
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'sync:progress']
|
||||
assert len(events) >= 1
|
||||
data = events[0]['args'][0]
|
||||
assert data['playlist_id'] == sync_id
|
||||
assert data['status'] == 'syncing'
|
||||
|
||||
client.disconnect()
|
||||
|
||||
@pytest.mark.parametrize('platform,sync_id', PLATFORM_SYNC_IDS)
|
||||
def test_platform_sync_not_received_when_unsubscribed(
|
||||
self, test_app, shared_state, platform, sync_id):
|
||||
|
|
@ -796,35 +640,12 @@ class TestPlatformSyncEventDelivery:
|
|||
|
||||
client.disconnect()
|
||||
|
||||
@pytest.mark.parametrize('platform,sync_id', PLATFORM_SYNC_IDS)
|
||||
def test_platform_sync_http_still_works(self, flask_client, platform, sync_id):
|
||||
"""GET /api/sync/status/<platform_sync_id> returns 200."""
|
||||
resp = flask_client.get(f'/api/sync/status/{sync_id}')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'status' in data
|
||||
assert 'progress' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group H — Wishlist Stats (broadcast)
|
||||
# =========================================================================
|
||||
|
||||
class TestWishlistStatsEventDelivery:
|
||||
"""wishlist:stats broadcast events are received by the client."""
|
||||
|
||||
def test_wishlist_stats_event_received(self, test_app, shared_state):
|
||||
"""Client receives a wishlist:stats event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_wishlist_stats']
|
||||
|
||||
socketio.emit('wishlist:stats', build())
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'wishlist:stats']
|
||||
assert len(events) >= 1
|
||||
client.disconnect()
|
||||
|
||||
def test_wishlist_stats_data_shape(self, test_app, shared_state):
|
||||
"""wishlist:stats has is_auto_processing and next_run_in_seconds."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -843,14 +664,6 @@ class TestWishlistStatsEventDelivery:
|
|||
assert isinstance(data['next_run_in_seconds'], (int, float))
|
||||
client.disconnect()
|
||||
|
||||
def test_wishlist_stats_http_still_works(self, flask_client):
|
||||
"""GET /api/wishlist/stats returns 200 with expected keys."""
|
||||
resp = flask_client.get('/api/wishlist/stats')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'is_auto_processing' in data
|
||||
assert 'next_run_in_seconds' in data
|
||||
|
||||
def test_wishlist_stats_auto_processing_detection(self, test_app, shared_state):
|
||||
"""When is_auto_processing is True, client detects it."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -38,19 +38,6 @@ ENDPOINTS = {
|
|||
class TestToolEventDelivery:
|
||||
"""tool:<name> socket events are received by the client."""
|
||||
|
||||
@pytest.mark.parametrize('tool', TOOLS)
|
||||
def test_tool_event_received(self, test_app, shared_state, tool):
|
||||
"""Client receives a tool:<name> event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_tool_status']
|
||||
|
||||
socketio.emit(f'tool:{tool}', build(tool))
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == f'tool:{tool}']
|
||||
assert len(events) >= 1
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group B — Data Shape (individual per tool)
|
||||
# =========================================================================
|
||||
|
|
@ -255,41 +242,11 @@ class TestToolHttpParity:
|
|||
|
||||
|
||||
# =========================================================================
|
||||
# Group D — HTTP Still Works (parameterized)
|
||||
# =========================================================================
|
||||
|
||||
class TestToolHttpStillWorks:
|
||||
"""HTTP endpoints return 200 with expected structure."""
|
||||
|
||||
@pytest.mark.parametrize('tool', TOOLS)
|
||||
def test_http_tool_still_works(self, flask_client, tool):
|
||||
"""GET /api/<tool>/status returns 200."""
|
||||
endpoint = ENDPOINTS[tool]
|
||||
resp = flask_client.get(endpoint)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
if tool == 'logs':
|
||||
assert 'logs' in data
|
||||
elif tool == 'metadata':
|
||||
assert 'success' in data
|
||||
assert 'status' in data
|
||||
else:
|
||||
assert 'status' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group E — Backward Compatibility
|
||||
# Group D — Backward Compatibility
|
||||
# =========================================================================
|
||||
|
||||
class TestToolBackwardCompat:
|
||||
"""HTTP endpoints work when no WebSocket is connected."""
|
||||
|
||||
def test_all_http_endpoints_work_without_socket(self, flask_client):
|
||||
"""All 7 tool HTTP endpoints work without any WebSocket connection."""
|
||||
for tool in TOOLS:
|
||||
endpoint = ENDPOINTS[tool]
|
||||
resp = flask_client.get(endpoint)
|
||||
assert resp.status_code == 200
|
||||
"""WebSocket clients still receive broadcast tool updates."""
|
||||
|
||||
def test_multiple_clients_get_tool_updates(self, test_app, shared_state):
|
||||
"""Multiple WebSocket clients each receive tool events."""
|
||||
|
|
@ -35,34 +35,6 @@ class TestInfrastructure:
|
|||
assert client.is_connected()
|
||||
client.disconnect()
|
||||
|
||||
def test_http_status_still_works(self, flask_client):
|
||||
"""GET /status returns 200 with expected keys."""
|
||||
resp = flask_client.get('/status')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'spotify' in data
|
||||
assert 'media_server' in data
|
||||
assert 'soulseek' in data
|
||||
assert 'active_media_server' in data
|
||||
|
||||
def test_http_watchlist_count_still_works(self, flask_client):
|
||||
"""GET /api/watchlist/count returns 200 with expected keys."""
|
||||
resp = flask_client.get('/api/watchlist/count')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['success'] is True
|
||||
assert 'count' in data
|
||||
assert 'next_run_in_seconds' in data
|
||||
|
||||
def test_http_download_batch_still_works(self, flask_client):
|
||||
"""GET /api/download_status/batch returns 200 with expected structure."""
|
||||
resp = flask_client.get('/api/download_status/batch')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'batches' in data
|
||||
assert 'metadata' in data
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group B — Service Status Parity
|
||||
# =========================================================================
|
||||
|
|
@ -70,16 +42,6 @@ class TestInfrastructure:
|
|||
class TestServiceStatus:
|
||||
"""status:update socket events match GET /status HTTP responses."""
|
||||
|
||||
def test_status_update_received(self, test_app, shared_state):
|
||||
"""Client receives a status:update event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_status_payload']
|
||||
socketio.emit('status:update', build())
|
||||
received = client.get_received()
|
||||
status_events = [e for e in received if e['name'] == 'status:update']
|
||||
assert len(status_events) >= 1
|
||||
|
||||
def test_status_update_shape(self, test_app, shared_state):
|
||||
"""status:update event data has the expected keys."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -139,16 +101,6 @@ class TestServiceStatus:
|
|||
class TestWatchlistCount:
|
||||
"""watchlist:count socket events match GET /api/watchlist/count."""
|
||||
|
||||
def test_watchlist_count_received(self, test_app, shared_state):
|
||||
"""Client receives a watchlist:count event."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_watchlist_count_payload']
|
||||
socketio.emit('watchlist:count', build())
|
||||
received = client.get_received()
|
||||
wl_events = [e for e in received if e['name'] == 'watchlist:count']
|
||||
assert len(wl_events) >= 1
|
||||
|
||||
def test_watchlist_count_shape(self, test_app, shared_state):
|
||||
"""watchlist:count event data has expected keys."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -222,12 +174,6 @@ class TestDownloadBatch:
|
|||
with lock:
|
||||
batches[batch_id] = defaults
|
||||
|
||||
def test_download_subscribe(self, test_app):
|
||||
"""Client can subscribe to a batch room."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
client.emit('downloads:subscribe', {'batch_ids': ['batch_abc']})
|
||||
|
||||
def test_download_receives_updates(self, test_app, shared_state):
|
||||
"""After subscribing, client receives batch_update for that batch."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -338,47 +284,6 @@ class TestDownloadBatch:
|
|||
assert 'active_count' in data
|
||||
assert 'max_concurrent' in data
|
||||
|
||||
def test_download_http_batch_still_works(self, test_app, shared_state):
|
||||
"""HTTP batch endpoint works alongside WebSocket rooms."""
|
||||
app, socketio = test_app
|
||||
flask_client = app.test_client()
|
||||
|
||||
self._add_batch(shared_state, 'batch_http')
|
||||
|
||||
resp = flask_client.get('/api/download_status/batch?batch_ids=batch_http')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'batch_http' in data['batches']
|
||||
assert data['batches']['batch_http']['phase'] == 'downloading'
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group E — Fallback Behavior
|
||||
# =========================================================================
|
||||
|
||||
class TestFallback:
|
||||
"""HTTP endpoints work when no WebSocket is connected."""
|
||||
|
||||
def test_http_works_without_websocket(self, flask_client):
|
||||
"""All three HTTP endpoints work without any WebSocket connection."""
|
||||
# Status
|
||||
resp = flask_client.get('/status')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['spotify']['connected'] is True
|
||||
|
||||
# Watchlist
|
||||
resp = flask_client.get('/api/watchlist/count')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['count'] == 7
|
||||
|
||||
# Download batch (empty — no active batches)
|
||||
resp = flask_client.get('/api/download_status/batch')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['batches'] == {}
|
||||
|
||||
def test_multiple_clients_get_updates(self, test_app, shared_state):
|
||||
"""Multiple WebSocket clients each receive broadcast events."""
|
||||
app, socketio = test_app
|
||||
File diff suppressed because it is too large
Load diff
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
|
|
@ -68,7 +68,7 @@ from core.soulseek_client import SoulseekClient
|
|||
from core.download_orchestrator import DownloadOrchestrator
|
||||
from core.tidal_client import TidalClient # Added import for Tidal
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
||||
from core.database_update_worker import DatabaseUpdateWorker
|
||||
from core.web_scan_manager import WebScanManager
|
||||
from core.lyrics_client import lyrics_client
|
||||
from core.metadata_cache import get_metadata_cache
|
||||
|
|
@ -4066,7 +4066,7 @@ def _find_downloaded_file(download_path, track_data):
|
|||
return None
|
||||
|
||||
# --- Refactored Logic from GUI Threads ---
|
||||
# This logic is extracted from your QThread classes to be used directly by Flask.
|
||||
# This logic is extracted from the database update worker to be used directly by Flask.
|
||||
|
||||
def run_service_test(service, test_config):
|
||||
"""
|
||||
|
|
@ -24238,7 +24238,7 @@ def _run_db_update_task(full_refresh, server_type):
|
|||
db_update_worker.connect_callback('finished', _db_update_finished_callback)
|
||||
db_update_worker.connect_callback('error', _db_update_error_callback)
|
||||
|
||||
# This is a blocking call that runs the QThread's logic
|
||||
# This is a blocking call that runs the worker logic
|
||||
db_update_worker.run()
|
||||
|
||||
|
||||
|
|
@ -24289,7 +24289,7 @@ def _run_deep_scan_task(server_type):
|
|||
def get_database_stats():
|
||||
"""Endpoint to get current database statistics."""
|
||||
try:
|
||||
# This logic is adapted from DatabaseStatsWorker
|
||||
# This endpoint returns the same stats shape the UI expects.
|
||||
db = get_database()
|
||||
stats = db.get_database_info_for_server()
|
||||
return jsonify(stats)
|
||||
|
|
|
|||
Loading…
Reference in a new issue