navidrome functionality start

This commit is contained in:
Broque Thomas 2025-09-21 21:02:12 -07:00
parent 0f64d17bf6
commit fcee0194e7
9 changed files with 1152 additions and 62 deletions

View file

@ -53,14 +53,17 @@ class DatabaseUpdateWorker(QThread):
error = pyqtSignal(str) # error_message
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex"):
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': [],
'artist_processed': [],
'finished': [],
'error': [],
'phase_changed': []
@ -93,12 +96,15 @@ class DatabaseUpdateWorker(QThread):
database_config = config_manager.get('database', {})
base_max_workers = database_config.get('max_workers', 5)
# Optimize worker count - reduce for database concurrency safety
# Optimize worker count - reduce for database concurrency safety
if self.server_type == "jellyfin":
# Reduce workers to prevent database lock issues with bulk inserts
self.max_workers = min(base_max_workers, 3) # Max 3 workers for database safety
if base_max_workers > 3:
logger.info(f"Reducing worker count from {base_max_workers} to {self.max_workers} for Jellyfin database safety")
elif self.server_type == "navidrome":
# Navidrome uses standard worker count like Plex
self.max_workers = base_max_workers
else:
# Plex uses standard worker count
self.max_workers = base_max_workers
@ -128,32 +134,42 @@ class DatabaseUpdateWorker(QThread):
"""Stop the database update process"""
self.should_stop = True
# Clear Jellyfin cache when user stops scan to free memory
if self.server_type == "jellyfin" and hasattr(self, 'media_client'):
# Clear media client cache when user stops scan to free memory
if self.server_type in ["jellyfin", "navidrome"] and hasattr(self, 'media_client'):
try:
cache_stats = self.media_client.get_cache_stats()
if hasattr(self.media_client, 'get_cache_stats'):
cache_stats = self.media_client.get_cache_stats()
freed_items = cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)
else:
freed_items = "unknown"
self.media_client.clear_cache()
logger.info(f"🧹 Cleared Jellyfin cache after user stop - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory")
logger.info(f"🧹 Cleared {self.server_type} cache after user stop - freed ~{freed_items} items from memory")
except Exception as e:
logger.warning(f"Could not clear Jellyfin cache on stop: {e}")
logger.warning(f"Could not clear {self.server_type} cache on stop: {e}")
def run(self):
"""Main worker thread execution"""
try:
# Initialize database
self.database = get_database(self.database_path)
if self.full_refresh:
logger.info(f"Performing full database refresh for {self.server_type} - clearing existing {self.server_type} data")
self.database.clear_server_data(self.server_type)
# Show cache preparation phase for Jellyfin and set up progress callback
if self.server_type == "jellyfin":
self._emit_signal('phase_changed', "Preparing Jellyfin cache for fast processing...")
# Connect Jellyfin client progress to UI
if hasattr(self.media_client, 'set_progress_callback'):
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
elif self.server_type == "navidrome":
self._emit_signal('phase_changed', "Connecting to Navidrome server...")
# Connect Navidrome client progress to UI
if hasattr(self.media_client, 'set_progress_callback'):
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
logger.info("✅ Connected Navidrome progress callback")
# For full refresh, get all artists
artists_to_process = self._get_all_artists()
if not artists_to_process:
@ -173,12 +189,13 @@ class DatabaseUpdateWorker(QThread):
# Phase 2: Process artists and their albums/tracks
self._emit_signal('phase_changed', "Processing artists, albums, and tracks...")
# FAST PATH: For Jellyfin track-based incremental, process new tracks directly
if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'):
self._process_jellyfin_new_tracks_directly(artists_to_process)
else:
# Standard artist processing for Plex or full refresh
logger.info(f"🎯 About to process {len(artists_to_process) if artists_to_process else 0} artists for {self.server_type}")
self._process_all_artists(artists_to_process)
# Record full refresh completion for tracking purposes
@ -189,14 +206,18 @@ class DatabaseUpdateWorker(QThread):
except Exception as e:
logger.warning(f"Could not record full refresh completion: {e}")
# Clear Jellyfin cache after full refresh to free memory
if self.full_refresh and self.server_type == "jellyfin":
# Clear cache after full refresh to free memory
if self.full_refresh and self.server_type in ["jellyfin", "navidrome"]:
try:
cache_stats = self.media_client.get_cache_stats()
if hasattr(self.media_client, 'get_cache_stats'):
cache_stats = self.media_client.get_cache_stats()
freed_items = cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)
else:
freed_items = "cache data"
self.media_client.clear_cache()
logger.info(f"🧹 Cleared Jellyfin cache after full refresh - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory")
logger.info(f"🧹 Cleared {self.server_type} cache after full refresh - freed ~{freed_items} items from memory")
except Exception as e:
logger.warning(f"Could not clear Jellyfin cache: {e}")
logger.warning(f"Could not clear {self.server_type} cache: {e}")
# Cleanup orphaned records after incremental updates (catches fixed matches)
if not self.full_refresh and self.database:
@ -236,10 +257,12 @@ class DatabaseUpdateWorker(QThread):
if not self.media_client.ensure_connection():
logger.error(f"Could not connect to {self.server_type} server")
return []
logger.info(f"🎯 _get_all_artists: Calling media_client.get_all_artists() for {self.server_type}")
artists = self.media_client.get_all_artists()
logger.info(f"🎯 _get_all_artists: Received {len(artists) if artists else 0} artists from {self.server_type}")
return artists
except Exception as e:
logger.error(f"Error getting artists from {self.server_type}: {e}")
return []
@ -288,11 +311,17 @@ class DatabaseUpdateWorker(QThread):
if self.server_type == "jellyfin":
if hasattr(self.media_client, 'set_progress_callback'):
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', f"Incremental: {msg}"))
elif self.server_type == "navidrome":
# Navidrome doesn't need cache preparation for incremental updates
logger.info("Navidrome incremental update: no caching needed")
# PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster)
if self.server_type == "jellyfin":
return self._get_artists_for_jellyfin_track_incremental_update()
elif self.server_type == "navidrome":
# Navidrome: simple approach - get all artists and check what's new in database
return self._get_artists_for_navidrome_incremental_update()
# Plex uses album-based approach (established and working)
recent_albums = self._get_recent_albums_for_server()
if not recent_albums:
@ -450,6 +479,107 @@ class DatabaseUpdateWorker(QThread):
logger.error(f"Error in smart incremental update: {e}")
# Fallback to empty list - user can try full refresh
return []
def _get_artists_for_navidrome_incremental_update(self) -> List:
"""Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin"""
try:
logger.info("🎵 Navidrome incremental: Getting recent albums and checking for new content...")
# Get recent albums from Navidrome (use the generic method that calls Navidrome-specific logic)
recent_albums = self._get_recent_albums_for_server()
if not recent_albums:
logger.info("No recent albums found - nothing to process")
return []
logger.info(f"Found {len(recent_albums)} recent albums to check")
# Sort albums by added date (newest first) - handle None dates properly
try:
def get_sort_date(album):
date_val = getattr(album, 'addedAt', None)
if date_val is None:
return 0 # Fallback for albums with no date
return date_val
recent_albums.sort(key=get_sort_date, reverse=True)
logger.info("Sorted albums by recently added date (newest first)")
except Exception as e:
logger.warning(f"Could not sort albums by date: {e}")
# Extract artists from recent albums with early stopping logic (same as Plex/Jellyfin)
artists_to_process = []
processed_artist_ids = set()
consecutive_complete_albums = 0
total_tracks_checked = 0
logger.info("Checking artists from recent albums (with early stopping)...")
for i, album in enumerate(recent_albums):
if self.should_stop:
break
try:
# Ensure this is actually an album object
if not hasattr(album, 'tracks'):
logger.warning(f"Skipping invalid album object at index {i}: {type(album).__name__}")
continue
album_title = getattr(album, 'title', f'Album_{i}')
album_has_new_tracks = False
# Check if album's tracks are already in database
try:
album_tracks = album.tracks()
total_tracks_checked += len(album_tracks)
for track in album_tracks:
if not self.database.track_exists(track.ratingKey, self.server_type):
album_has_new_tracks = True
consecutive_complete_albums = 0 # Reset counter
break
# If no new tracks found, increment consecutive complete counter
if not album_has_new_tracks:
consecutive_complete_albums += 1
logger.debug(f"✅ Album '{album_title}' is up-to-date (consecutive: {consecutive_complete_albums})")
# Early stopping after 25 consecutive complete albums (same as Plex/Jellyfin)
if consecutive_complete_albums >= 25:
logger.info(f"🛑 Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
break
except Exception as tracks_error:
logger.warning(f"Error getting tracks for album '{album_title}': {tracks_error}")
# Assume album needs processing if we can't check tracks
album_has_new_tracks = True
consecutive_complete_albums = 0
# If album has new tracks, queue its artist for processing
if album_has_new_tracks:
try:
album_artist = album.artist()
if album_artist:
artist_id = str(album_artist.ratingKey)
# Skip if we've already queued this artist
if artist_id not in processed_artist_ids:
processed_artist_ids.add(artist_id)
artists_to_process.append(album_artist)
logger.info(f"✅ Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
except Exception as artist_error:
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
except Exception as e:
logger.warning(f"Error processing album at index {i}: {e}")
consecutive_complete_albums = 0 # Reset on error
continue
logger.info(f"🎵 Navidrome incremental complete: {len(artists_to_process)} artists need processing (checked {total_tracks_checked} tracks from {len(recent_albums)} recent albums)")
return artists_to_process
except Exception as e:
logger.error(f"Error in Navidrome incremental update: {e}")
return []
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
@ -694,6 +824,8 @@ class DatabaseUpdateWorker(QThread):
return self._get_recent_albums_plex()
elif self.server_type == "jellyfin":
return self._get_recent_albums_jellyfin()
elif self.server_type == "navidrome":
return self._get_recent_albums_navidrome()
else:
logger.error(f"Unknown server type: {self.server_type}")
return []
@ -782,10 +914,62 @@ class DatabaseUpdateWorker(QThread):
except Exception as e:
logger.error(f"Error getting recent Jellyfin albums: {e}")
return []
def _get_recent_albums_navidrome(self) -> List:
"""Get recently added albums from Navidrome using all albums sorted by date"""
try:
logger.info("Getting recent albums from Navidrome...")
# Navidrome doesn't have a direct "recent albums" API like Plex/Jellyfin
# So we need to get all albums and sort them by date
all_artists = self.media_client.get_all_artists()
if not all_artists:
return []
all_albums = []
# Get albums from a subset of artists to avoid too much data
# Take first 200 artists to get a reasonable sample of recent albums
sample_artists = all_artists[:200]
for artist in sample_artists:
try:
artist_albums = self.media_client.get_albums_for_artist(artist.ratingKey)
all_albums.extend(artist_albums)
except Exception as e:
logger.warning(f"Error getting albums for artist {getattr(artist, 'title', 'Unknown')}: {e}")
continue
if not all_albums:
return []
# Sort by addedAt date (newest first) and take recent ones
try:
def get_sort_date(album):
date_val = getattr(album, 'addedAt', None)
if date_val is None:
return 0
return date_val
all_albums.sort(key=get_sort_date, reverse=True)
# Take the most recent 400 albums for incremental checking
recent_albums = all_albums[:400]
logger.info(f"Found {len(recent_albums)} recent albums from Navidrome (from {len(all_albums)} total)")
return recent_albums
except Exception as e:
logger.warning(f"Error sorting Navidrome albums by date: {e}")
# If sorting fails, just return the first 400 albums
return all_albums[:400]
except Exception as e:
logger.error(f"Error getting recent Navidrome albums: {e}")
return []
def _process_all_artists(self, artists: List):
"""Process all artists and their albums/tracks using thread pool"""
total_artists = len(artists)
logger.info(f"🎯 Processing {total_artists} artists with progress tracking")
def process_single_artist(artist):
"""Process a single artist and return results"""
@ -806,6 +990,7 @@ class DatabaseUpdateWorker(QThread):
total_artists,
progress_percent
)
logger.debug(f"🔄 Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}")
# Process the artist
success, details, album_count, track_count = self._process_artist_with_content(artist)
@ -826,25 +1011,41 @@ 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 in parallel using ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Submit all tasks
future_to_artist = {executor.submit(process_single_artist, artist): artist
for artist in artists}
# Process completed tasks as they finish
for future in as_completed(future_to_artist):
# Process artists - use sequential processing in web server mode to avoid threading issues
if not QT_AVAILABLE or self.force_sequential:
# Sequential processing for web server mode
for i, artist in enumerate(artists):
if self.should_stop:
break
result = future.result()
result = process_single_artist(artist)
if result is None: # Task was cancelled
continue
artist_name, success, details, album_count, track_count = result
# 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)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Submit all tasks
future_to_artist = {executor.submit(process_single_artist, artist): artist
for artist in artists}
# Process completed tasks as they finish
for future in as_completed(future_to_artist):
if self.should_stop:
break
result = future.result()
if result is None: # Task was cancelled
continue
artist_name, success, details, album_count, track_count = result
# Emit progress signal
self._emit_signal('artist_processed', artist_name, success, details, album_count, track_count)
def _process_artist_with_content(self, media_artist) -> tuple[bool, str, int, int]:
"""Process an artist and all their albums and tracks with optimized API usage"""

View file

@ -59,7 +59,8 @@ class MediaScanManager:
# 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'):
if (hasattr(widget, 'plex_client') and hasattr(widget, 'jellyfin_client') and
hasattr(widget, 'navidrome_client')):
main_window = widget
break
@ -70,7 +71,13 @@ class MediaScanManager:
return client, "jellyfin"
else:
logger.warning("Jellyfin client not connected, falling back to Plex")
elif active_server == "navidrome":
client = getattr(main_window, 'navidrome_client', None)
if client and client.is_connected():
return client, "navidrome"
else:
logger.warning("Navidrome client not connected, falling back to Plex")
# Default to Plex or fallback
client = getattr(main_window, 'plex_client', None)
if client and client.is_connected():
@ -88,12 +95,17 @@ class MediaScanManager:
# Headless mode - try to get clients from global instances
import sys
for module_name, module in sys.modules.items():
if hasattr(module, 'plex_client') and hasattr(module, 'jellyfin_client'):
if (hasattr(module, 'plex_client') and hasattr(module, 'jellyfin_client') and
hasattr(module, 'navidrome_client')):
if active_server == "jellyfin":
client = getattr(module, 'jellyfin_client', None)
if client and hasattr(client, 'is_connected') and client.is_connected():
return client, "jellyfin"
elif active_server == "navidrome":
client = getattr(module, 'navidrome_client', None)
if client and hasattr(client, 'is_connected') and client.is_connected():
return client, "navidrome"
client = getattr(module, 'plex_client', None)
if client and hasattr(client, 'is_connected') and client.is_connected():
return client, "plex"

774
core/navidrome_client.py Normal file
View file

@ -0,0 +1,774 @@
import requests
import hashlib
import secrets
from typing import List, Optional, Dict, Any
from dataclasses import dataclass
from datetime import datetime
import json
from utils.logging_config import get_logger
from config.settings import config_manager
logger = get_logger("navidrome_client")
@dataclass
class NavidromeTrackInfo:
id: str
title: str
artist: str
album: str
duration: int
track_number: Optional[int] = None
year: Optional[int] = None
rating: Optional[float] = None
@dataclass
class NavidromePlaylistInfo:
id: str
title: str
description: Optional[str]
duration: int
leaf_count: int
tracks: List[NavidromeTrackInfo]
class NavidromeArtist:
"""Wrapper class to mimic Plex artist object interface"""
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
self._data = navidrome_data
self._client = client
self.ratingKey = navidrome_data.get('id', '')
self.title = navidrome_data.get('name', 'Unknown Artist')
self.addedAt = self._parse_date(navidrome_data.get('dateAdded'))
# Create genres property from Navidrome data
self.genres = []
# TODO: Map Navidrome genre data to match Plex format
# Create summary property (used for timestamp storage)
self.summary = navidrome_data.get('biography', '') or ''
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
"""Parse Navidrome date string to datetime"""
if not date_str:
return None
try:
# Navidrome uses ISO format
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except:
return None
def albums(self) -> List['NavidromeAlbum']:
"""Get all albums for this artist"""
return self._client.get_albums_for_artist(self.ratingKey)
class NavidromeAlbum:
"""Wrapper class to mimic Plex album object interface"""
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
self._data = navidrome_data
self._client = client
self.ratingKey = navidrome_data.get('id', '')
self.title = navidrome_data.get('name', 'Unknown Album')
self.year = navidrome_data.get('year')
self.addedAt = self._parse_date(navidrome_data.get('created'))
self._artist_id = navidrome_data.get('artistId', '')
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
if not date_str:
return None
try:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except:
return None
def artist(self) -> Optional[NavidromeArtist]:
"""Get the album artist"""
if self._artist_id:
return self._client.get_artist_by_id(self._artist_id)
return None
def tracks(self) -> List['NavidromeTrack']:
"""Get all tracks for this album"""
return self._client.get_tracks_for_album(self.ratingKey)
class NavidromeTrack:
"""Wrapper class to mimic Plex track object interface"""
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
self._data = navidrome_data
self._client = client
self.ratingKey = navidrome_data.get('id', '')
self.title = navidrome_data.get('title', 'Unknown Track')
self.duration = navidrome_data.get('duration', 0) * 1000 # Convert to milliseconds
self.trackNumber = navidrome_data.get('track')
self.year = navidrome_data.get('year')
self.userRating = navidrome_data.get('userRating')
self.addedAt = self._parse_date(navidrome_data.get('created'))
self._album_id = navidrome_data.get('albumId', '')
self._artist_id = navidrome_data.get('artistId', '')
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
if not date_str:
return None
try:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except:
return None
def artist(self) -> Optional[NavidromeArtist]:
"""Get the track artist"""
if self._artist_id:
return self._client.get_artist_by_id(self._artist_id)
return None
def album(self) -> Optional[NavidromeAlbum]:
"""Get the track's album"""
if self._album_id:
return self._client.get_album_by_id(self._album_id)
return None
class NavidromeClient:
def __init__(self):
self.base_url: Optional[str] = None
self.username: Optional[str] = None
self.password: Optional[str] = None
self._connection_attempted = False
self._is_connecting = False
# Cache for performance
self._artist_cache = {}
self._album_cache = {}
self._track_cache = {}
# Progress callback for UI updates
self._progress_callback = None
def set_progress_callback(self, callback):
"""Set callback function for progress updates"""
self._progress_callback = callback
def ensure_connection(self) -> bool:
"""Ensure connection to Navidrome server with lazy initialization."""
if self._connection_attempted:
return self.base_url is not None and self.username is not None
if self._is_connecting:
return False
self._is_connecting = True
try:
self._setup_client()
return self.base_url is not None and self.username is not None
finally:
self._is_connecting = False
self._connection_attempted = True
def _setup_client(self):
"""Setup Navidrome client configuration"""
config = config_manager.get_navidrome_config()
if not config.get('base_url'):
logger.warning("Navidrome server URL not configured")
return
if not config.get('username') or not config.get('password'):
logger.warning("Navidrome username/password not configured")
return
self.base_url = config['base_url'].rstrip('/')
self.username = config['username']
self.password = config['password']
try:
# Test connection with ping
response = self._make_request('ping')
if response and response.get('status') == 'ok':
server_version = response.get('version', 'Unknown')
logger.info(f"Successfully connected to Navidrome server version: {server_version}")
else:
logger.error("Failed to connect to Navidrome server")
self.base_url = None
self.username = None
self.password = None
except Exception as e:
logger.error(f"Failed to connect to Navidrome server: {e}")
self.base_url = None
self.username = None
self.password = None
def _generate_auth_params(self) -> Dict[str, str]:
"""Generate authentication parameters for Subsonic API"""
if not self.username or not self.password:
return {}
# Generate random salt (at least 6 characters)
salt = secrets.token_hex(8)
# Calculate token: md5(password + salt)
token = hashlib.md5((self.password + salt).encode()).hexdigest()
return {
'u': self.username,
't': token,
's': salt,
'v': '1.16.1', # API version
'c': 'SoulSync', # Client name
'f': 'json' # Response format
}
def _make_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make authenticated request to Navidrome Subsonic API"""
if not self.base_url or not self.username:
return None
url = f"{self.base_url}/rest/{endpoint}"
# Add authentication parameters
auth_params = self._generate_auth_params()
if params:
auth_params.update(params)
try:
response = requests.get(url, params=auth_params, timeout=10)
response.raise_for_status()
data = response.json()
# Check for Subsonic API errors
subsonic_response = data.get('subsonic-response', {})
if subsonic_response.get('status') == 'failed':
error = subsonic_response.get('error', {})
error_message = error.get('message', 'Unknown error')
logger.error(f"Navidrome API error: {error_message}")
return None
return subsonic_response
except requests.exceptions.RequestException as e:
logger.error(f"Navidrome API request failed: {e}")
return None
except json.JSONDecodeError as e:
logger.error(f"Failed to parse Navidrome response: {e}")
return None
def is_connected(self) -> bool:
"""Check if connected to Navidrome server"""
if not self._connection_attempted:
if not self._is_connecting:
self.ensure_connection()
return (self.base_url is not None and
self.username is not None and
self.password is not None)
def get_all_artists(self) -> List[NavidromeArtist]:
"""Get all artists from the music library"""
if not self.ensure_connection():
logger.error("Not connected to Navidrome server")
return []
try:
if self._progress_callback:
self._progress_callback("Fetching artists from Navidrome...")
response = self._make_request('getArtists')
if not response:
return []
if self._progress_callback:
self._progress_callback("Processing artist data...")
artists = []
indexes = response.get('artists', {}).get('index', [])
total_indexes = len(indexes)
for i, index in enumerate(indexes):
if self._progress_callback and total_indexes > 1:
progress_pct = int((i / total_indexes) * 100)
self._progress_callback(f"Processing artist index {i+1}/{total_indexes} ({progress_pct}%)")
for artist_data in index.get('artist', []):
artist = NavidromeArtist(artist_data, self)
# Cache the artist for quick lookup
self._artist_cache[artist.ratingKey] = artist
artists.append(artist)
if self._progress_callback:
self._progress_callback(f"Retrieved {len(artists)} artists from Navidrome")
logger.info(f"Retrieved {len(artists)} artists from Navidrome")
return artists
except Exception as e:
logger.error(f"Error getting artists from Navidrome: {e}")
return []
def get_albums_for_artist(self, artist_id: str) -> List[NavidromeAlbum]:
"""Get all albums for a specific artist"""
# Check cache first
if artist_id in self._album_cache:
return self._album_cache[artist_id]
if not self.ensure_connection():
return []
try:
# Get artist name for progress display
artist_name = "Unknown Artist"
if hasattr(self, '_artist_cache'):
for cached_artist in self._artist_cache.values():
if getattr(cached_artist, 'ratingKey', None) == artist_id:
artist_name = getattr(cached_artist, 'title', 'Unknown Artist')
break
if self._progress_callback:
self._progress_callback(f"Fetching albums for artist {artist_name}...")
response = self._make_request('getArtist', {'id': artist_id})
if not response:
return []
albums = []
artist_data = response.get('artist', {})
album_list = artist_data.get('album', [])
if self._progress_callback and album_list:
self._progress_callback(f"Processing {len(album_list)} albums...")
for album_data in album_list:
albums.append(NavidromeAlbum(album_data, self))
# Cache the result
self._album_cache[artist_id] = albums
return albums
except Exception as e:
logger.error(f"Error getting albums for artist {artist_id}: {e}")
return []
def get_tracks_for_album(self, album_id: str) -> List[NavidromeTrack]:
"""Get all tracks for a specific album"""
# Check cache first
if album_id in self._track_cache:
return self._track_cache[album_id]
if not self.ensure_connection():
return []
try:
# Get album name for progress display
album_name = "Unknown Album"
if hasattr(self, '_album_cache'):
for artist_albums in self._album_cache.values():
for cached_album in artist_albums:
if getattr(cached_album, 'ratingKey', None) == album_id:
album_name = getattr(cached_album, 'title', 'Unknown Album')
break
if album_name != "Unknown Album":
break
if self._progress_callback:
self._progress_callback(f"Fetching tracks for album {album_name}...")
response = self._make_request('getAlbum', {'id': album_id})
if not response:
return []
tracks = []
album_data = response.get('album', {})
track_list = album_data.get('song', [])
if self._progress_callback and track_list:
self._progress_callback(f"Processing {len(track_list)} tracks...")
for track_data in track_list:
tracks.append(NavidromeTrack(track_data, self))
# Cache the result
self._track_cache[album_id] = tracks
return tracks
except Exception as e:
logger.error(f"Error getting tracks for album {album_id}: {e}")
return []
def get_artist_by_id(self, artist_id: str) -> Optional[NavidromeArtist]:
"""Get a specific artist by ID"""
# Check cache first
if artist_id in self._artist_cache:
return self._artist_cache[artist_id]
if not self.ensure_connection():
return None
try:
response = self._make_request('getArtist', {'id': artist_id})
if response and 'artist' in response:
artist = NavidromeArtist(response['artist'], self)
# Cache for future use
self._artist_cache[artist_id] = artist
return artist
return None
except Exception as e:
logger.error(f"Error getting artist {artist_id}: {e}")
return None
def get_album_by_id(self, album_id: str) -> Optional[NavidromeAlbum]:
"""Get a specific album by ID"""
if not self.ensure_connection():
return None
try:
response = self._make_request('getAlbum', {'id': album_id})
if response and 'album' in response:
return NavidromeAlbum(response['album'], self)
return None
except Exception as e:
logger.error(f"Error getting album {album_id}: {e}")
return None
def get_library_stats(self) -> Dict[str, int]:
"""Get library statistics"""
if not self.ensure_connection():
return {}
try:
# Get counts by making API calls
stats = {}
# Get artist count
artists = self.get_all_artists()
stats['artists'] = len(artists)
# For albums and tracks, we'd need to iterate through all artists
# This is expensive, so let's use reasonable estimates or make separate calls
# For now, return what we can efficiently get
stats['albums'] = 0
stats['tracks'] = 0
# TODO: Implement more efficient counting if Navidrome provides bulk stats
return stats
except Exception as e:
logger.error(f"Error getting library stats: {e}")
return {}
def get_all_playlists(self) -> List[NavidromePlaylistInfo]:
"""Get all playlists from Navidrome server"""
if not self.ensure_connection():
return []
try:
response = self._make_request('getPlaylists')
if not response:
return []
playlists = []
playlists_data = response.get('playlists', {}).get('playlist', [])
for playlist_data in playlists_data:
playlist_info = NavidromePlaylistInfo(
id=playlist_data.get('id', ''),
title=playlist_data.get('name', 'Unknown Playlist'),
description=playlist_data.get('comment'),
duration=playlist_data.get('duration', 0) * 1000, # Convert to milliseconds
leaf_count=playlist_data.get('songCount', 0),
tracks=[] # Will be populated when needed
)
playlists.append(playlist_info)
logger.info(f"Retrieved {len(playlists)} playlists from Navidrome")
return playlists
except Exception as e:
logger.error(f"Error getting playlists from Navidrome: {e}")
return []
def get_playlist_by_name(self, name: str) -> Optional[NavidromePlaylistInfo]:
"""Get a specific playlist by name"""
playlists = self.get_all_playlists()
for playlist in playlists:
if playlist.title.lower() == name.lower():
return playlist
return None
def create_playlist(self, name: str, tracks) -> bool:
"""Create a new playlist with given tracks"""
if not self.ensure_connection():
return False
try:
# Convert tracks to Navidrome track IDs
track_ids = []
for track in tracks:
if hasattr(track, 'ratingKey'):
track_ids.append(str(track.ratingKey))
elif hasattr(track, 'id'):
track_ids.append(str(track.id))
if not track_ids:
logger.warning(f"No valid tracks provided for playlist '{name}'")
return False
logger.info(f"Creating Navidrome playlist '{name}' with {len(track_ids)} tracks")
# Create playlist with tracks
params = {
'name': name,
'songId': track_ids # Subsonic API accepts multiple songId parameters
}
response = self._make_request('createPlaylist', params)
if response and response.get('status') == 'ok':
logger.info(f"✅ Created Navidrome playlist '{name}' with {len(track_ids)} tracks")
return True
else:
logger.error(f"Failed to create Navidrome playlist '{name}'")
return False
except Exception as e:
logger.error(f"Error creating Navidrome playlist '{name}': {e}")
return False
def copy_playlist(self, source_name: str, target_name: str) -> bool:
"""Copy a playlist to create a backup"""
if not self.ensure_connection():
return False
try:
# Get the source playlist
source_playlist = self.get_playlist_by_name(source_name)
if not source_playlist:
logger.error(f"Source playlist '{source_name}' not found")
return False
# Get tracks from source playlist
source_tracks = self.get_playlist_tracks(source_playlist.id)
logger.debug(f"Retrieved {len(source_tracks) if source_tracks else 0} tracks from source playlist")
# Validate tracks
if not source_tracks:
logger.warning(f"Source playlist '{source_name}' has no tracks to copy")
return False
# Delete target playlist if it exists (for overwriting backup)
try:
target_playlist = self.get_playlist_by_name(target_name)
if target_playlist:
self._make_request('deletePlaylist', {'id': target_playlist.id})
logger.info(f"Deleted existing backup playlist '{target_name}'")
except Exception:
pass # Target doesn't exist, which is fine
# Create new playlist with copied tracks
try:
success = self.create_playlist(target_name, source_tracks)
if success:
logger.info(f"✅ Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
return True
else:
logger.error(f"Failed to create backup playlist '{target_name}'")
return False
except Exception as create_error:
logger.error(f"Failed to create backup playlist: {create_error}")
return False
except Exception as e:
logger.error(f"Error copying playlist '{source_name}' to '{target_name}': {e}")
return False
def get_playlist_tracks(self, playlist_id: str) -> List[NavidromeTrack]:
"""Get all tracks from a specific playlist"""
if not self.ensure_connection():
return []
try:
response = self._make_request('getPlaylist', {'id': playlist_id})
if not response:
return []
tracks = []
playlist_data = response.get('playlist', {})
for track_data in playlist_data.get('entry', []):
tracks.append(NavidromeTrack(track_data, self))
logger.debug(f"Retrieved {len(tracks)} tracks from playlist {playlist_id}")
return tracks
except Exception as e:
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
return []
def update_playlist(self, playlist_name: str, tracks) -> bool:
"""Update an existing playlist or create it if it doesn't exist"""
if not self.ensure_connection():
return False
try:
existing_playlist = self.get_playlist_by_name(playlist_name)
# Check if backup is enabled in config
from config.settings import config_manager
create_backup = config_manager.get('playlist_sync.create_backup', True)
if existing_playlist and create_backup:
backup_name = f"{playlist_name} Backup"
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync")
if self.copy_playlist(playlist_name, backup_name):
logger.info(f"✅ Backup created successfully")
else:
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
if existing_playlist:
# Delete existing playlist
response = self._make_request('deletePlaylist', {'id': existing_playlist.id})
if response and response.get('status') == 'ok':
logger.info(f"Deleted existing Navidrome playlist '{playlist_name}'")
else:
logger.warning(f"Could not delete existing playlist '{playlist_name}', creating anyway")
# Create new playlist with tracks
return self.create_playlist(playlist_name, tracks)
except Exception as e:
logger.error(f"Error updating Navidrome playlist '{playlist_name}': {e}")
return False
def trigger_library_scan(self, library_name: str = "Music") -> bool:
"""Trigger Navidrome library scan - Navidrome doesn't have scanning, always returns True"""
logger.info(f"🎵 Navidrome doesn't require library scans - library is always current")
return True
def is_library_scanning(self, library_name: str = "Music") -> bool:
"""Check if Navidrome library is currently scanning - always returns False"""
return False
# Metadata update methods for compatibility with metadata updater
def update_artist_genres(self, artist, genres: List[str]):
"""Update artist genres - not implemented for Navidrome"""
try:
logger.debug(f"Genre update not implemented for Navidrome artist: {artist.title}")
return True
except Exception as e:
logger.error(f"Error updating genres for {artist.title}: {e}")
return False
def update_artist_poster(self, artist, image_data: bytes):
"""Update artist poster image - not implemented for Navidrome"""
try:
logger.debug(f"Poster update not implemented for Navidrome artist: {artist.title}")
return True
except Exception as e:
logger.error(f"Error updating poster for {artist.title}: {e}")
return False
def update_album_poster(self, album, image_data: bytes):
"""Update album poster image - not implemented for Navidrome"""
try:
logger.debug(f"Poster update not implemented for Navidrome album: {album.title}")
return True
except Exception as e:
logger.error(f"Error updating poster for album {album.title}: {e}")
return False
def update_artist_biography(self, artist) -> bool:
"""Update artist biography - not implemented for Navidrome"""
try:
logger.debug(f"Biography update not implemented for Navidrome artist: {artist.title}")
return True
except Exception as e:
logger.error(f"Error updating biography for {artist.title}: {e}")
return False
def needs_update_by_age(self, artist, refresh_interval_days: int) -> bool:
"""Check if artist needs updating based on age threshold - simplified for Navidrome"""
try:
# For now, just return True for all artists since we don't have timestamp tracking yet
return True
except Exception as e:
logger.debug(f"Error checking update age for {artist.title}: {e}")
return True
def is_artist_ignored(self, artist) -> bool:
"""Check if artist is manually marked to be ignored - simplified for Navidrome"""
try:
# For now, no artists are ignored
return False
except Exception as e:
logger.debug(f"Error checking ignore status for {artist.title}: {e}")
return False
def parse_update_timestamp(self, artist) -> Optional[datetime]:
"""Parse the last update timestamp from artist summary - not implemented for Navidrome"""
try:
return None
except Exception as e:
logger.debug(f"Error parsing timestamp for {artist.title}: {e}")
return None
def get_cache_stats(self):
"""Get cache statistics for debugging/logging"""
return {
'artists_cached': len(self._artist_cache),
'albums_cached': len(self._album_cache),
'tracks_cached': len(self._track_cache),
'bulk_albums_cached': len(self._album_cache), # For compatibility with Jellyfin interface
'bulk_tracks_cached': len(self._track_cache) # For compatibility with Jellyfin interface
}
def clear_cache(self):
"""Clear all caches to force fresh data on next request"""
self._artist_cache.clear()
self._album_cache.clear()
self._track_cache.clear()
logger.info("Navidrome client cache cleared")
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[NavidromeTrackInfo]:
"""Search for tracks using Navidrome search API"""
if not self.ensure_connection():
logger.warning("Navidrome not connected. Cannot perform search.")
return []
try:
# Use Subsonic search3 API for music search
query = f"{artist} {title}".strip()
response = self._make_request('search3', {
'query': query,
'songCount': limit,
'artistCount': 0,
'albumCount': 0
})
if not response:
return []
tracks = []
search_result = response.get('searchResult3', {})
for track_data in search_result.get('song', []):
track_info = NavidromeTrackInfo(
id=track_data.get('id', ''),
title=track_data.get('title', ''),
artist=track_data.get('artist', ''),
album=track_data.get('album', ''),
duration=track_data.get('duration', 0) * 1000, # Convert to milliseconds
track_number=track_data.get('track'),
year=track_data.get('year'),
rating=track_data.get('userRating')
)
# Store reference to original track for playlist creation
track_info._original_navidrome_track = NavidromeTrack(track_data, self)
tracks.append(track_info)
logger.info(f"Found {len(tracks)} tracks for '{title}' by '{artist}'")
return tracks
except Exception as e:
logger.error(f"Error searching for tracks: {e}")
return []

23
main.py
View file

@ -14,6 +14,7 @@ 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
@ -29,11 +30,12 @@ logger = get_logger("main")
class ServiceStatusThread(QThread):
status_updated = pyqtSignal(str, bool)
def __init__(self, spotify_client, plex_client, jellyfin_client, soulseek_client):
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
@ -57,6 +59,10 @@ class ServiceStatusThread(QThread):
# 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()
@ -83,6 +89,7 @@ class MainWindow(QMainWindow):
self.spotify_client = SpotifyClient()
self.plex_client = PlexClient()
self.jellyfin_client = JellyfinClient()
self.navidrome_client = NavidromeClient()
self.soulseek_client = SoulseekClient()
self.status_thread = None
@ -176,11 +183,12 @@ class MainWindow(QMainWindow):
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
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()
@ -192,7 +200,7 @@ class MainWindow(QMainWindow):
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.soulseek_client)
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)
@ -240,6 +248,7 @@ class MainWindow(QMainWindow):
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)

View file

@ -9,7 +9,7 @@ from utils.logging_config import get_logger
logger = get_logger("database_updater_widget")
class DatabaseUpdaterWidget(QFrame):
"""UI widget for updating SoulSync database with Plex library data"""
"""UI widget for updating SoulSync database with media server library data (Plex, Jellyfin, or Navidrome)"""
def __init__(self, parent=None):
super().__init__(parent)
@ -37,7 +37,12 @@ class DatabaseUpdaterWidget(QFrame):
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_name = "Jellyfin" if active_server == "jellyfin" else "Plex"
if active_server == "jellyfin":
server_name = "Jellyfin"
elif active_server == "navidrome":
server_name = "Navidrome"
else:
server_name = "Plex"
except:
server_name = "Plex" # Fallback

View file

@ -1960,11 +1960,12 @@ class DashboardDataProvider(QObject):
self.system_stats_timer.timeout.connect(self.update_system_stats)
self.system_stats_timer.start(10000) # Update every 10 seconds
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client):
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client):
self.service_clients = {
'spotify_client': spotify_client,
'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'navidrome_client': navidrome_client,
'soulseek_client': soulseek_client
}
@ -2712,15 +2713,16 @@ class DashboardPage(QWidget):
self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished)
return True
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client, downloads_page=None):
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client, downloads_page=None):
"""Called from main window to provide service client references"""
self.data_provider.set_service_clients(spotify_client, plex_client, jellyfin_client, soulseek_client)
self.data_provider.set_service_clients(spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client)
# Store service clients for wishlist modal
self.service_clients = {
'spotify_client': spotify_client,
'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'navidrome_client': navidrome_client,
'soulseek_client': soulseek_client,
'downloads_page': downloads_page
}

View file

@ -454,6 +454,8 @@ class ServiceTestThread(QThread):
success, message = self._test_plex()
elif self.service_type == "jellyfin":
success, message = self._test_jellyfin()
elif self.service_type == "navidrome":
success, message = self._test_navidrome()
elif self.service_type == "soulseek":
success, message = self._test_soulseek()
else:
@ -644,7 +646,72 @@ class ServiceTestThread(QThread):
return False, "✗ Cannot connect to Jellyfin server.\nCheck your server URL and network."
except Exception as e:
return False, f"✗ Jellyfin test failed:\n{str(e)}"
def _test_navidrome(self):
"""Test Navidrome connection"""
try:
import requests
import hashlib
import secrets
base_url = self.test_config['base_url']
username = self.test_config['username']
password = self.test_config['password']
if not base_url:
return False, "Please enter Navidrome server URL"
if not username:
return False, "Please enter Navidrome username"
if not password:
return False, "Please enter Navidrome password"
# Clean URL - remove trailing slash
if base_url.endswith('/'):
base_url = base_url[:-1]
# Generate authentication parameters for Subsonic API
salt = secrets.token_hex(8)
token = hashlib.md5((password + salt).encode()).hexdigest()
# Test connection with ping endpoint
params = {
'u': username,
't': token,
's': salt,
'v': '1.16.1',
'c': 'SoulSync',
'f': 'json'
}
test_url = f"{base_url}/rest/ping"
response = requests.get(test_url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
subsonic_response = data.get('subsonic-response', {})
if subsonic_response.get('status') == 'ok':
version = subsonic_response.get('version', 'Unknown')
message = f"✓ Navidrome connection successful!\nSubsonic API Version: {version}"
return True, message
elif subsonic_response.get('status') == 'failed':
error = subsonic_response.get('error', {})
error_message = error.get('message', 'Unknown error')
return False, f"✗ Navidrome authentication failed:\n{error_message}"
else:
return False, "✗ Unexpected response from Navidrome server"
else:
return False, f"✗ Navidrome connection failed.\nHTTP {response.status_code}: {response.text}"
except requests.exceptions.Timeout:
return False, "✗ Navidrome connection timeout.\nCheck your server URL."
except requests.exceptions.ConnectionError:
return False, "✗ Cannot connect to Navidrome server.\nCheck your server URL and network."
except Exception as e:
return False, f"✗ Navidrome test failed:\n{str(e)}"
def _test_soulseek(self):
"""Test Soulseek connection"""
try:
@ -1259,6 +1326,13 @@ class SettingsPage(QWidget):
'api_key': self.jellyfin_api_key_input.text()
}
self.start_service_test('jellyfin', test_config)
elif active_server == 'navidrome':
test_config = {
'base_url': self.navidrome_url_input.text(),
'username': self.navidrome_username_input.text(),
'password': self.navidrome_password_input.text()
}
self.start_service_test('navidrome', test_config)
else:
logger.warning(f"Unknown active server type: {active_server}")

View file

@ -23,6 +23,7 @@ from config.settings import config_manager
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
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 core.tidal_client import TidalClient # Added import for Tidal
from core.matching_engine import MusicMatchingEngine
@ -95,6 +96,7 @@ try:
spotify_client = SpotifyClient()
plex_client = PlexClient()
jellyfin_client = JellyfinClient()
navidrome_client = NavidromeClient()
soulseek_client = SoulseekClient()
tidal_client = TidalClient()
matching_engine = MusicMatchingEngine()
@ -102,7 +104,7 @@ try:
print("✅ Core service clients initialized.")
except Exception as e:
print(f"🔴 FATAL: Error initializing service clients: {e}")
spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = matching_engine = sync_service = None
spotify_client = plex_client = jellyfin_client = navidrome_client = soulseek_client = tidal_client = matching_engine = sync_service = None
# --- Global Streaming State Management ---
# Thread-safe state tracking for streaming functionality
@ -1490,10 +1492,8 @@ def get_status():
temp_jellyfin_client = JellyfinClient()
media_server_status = temp_jellyfin_client.is_connected()
elif active_server == "navidrome":
# Test Navidrome connection
navidrome_config = config_manager.get('navidrome', {})
success, _ = run_service_test('navidrome', navidrome_config)
media_server_status = success
# Test Navidrome connection using existing client instance (non-destructive)
media_server_status = navidrome_client.is_connected()
media_server_response_time = (time.time() - media_server_start) * 1000
# Test Soulseek (just check if configured, no network test)
@ -5589,6 +5589,7 @@ def _process_wishlist_automatically():
# ===============================
def _db_update_progress_callback(current_item, processed, total, percentage):
print(f"📊 [DB Progress] {current_item} - {processed}/{total} ({percentage:.1f}%)")
with db_update_lock:
db_update_state.update({
"current_item": current_item,
@ -5598,6 +5599,7 @@ def _db_update_progress_callback(current_item, processed, total, percentage):
})
def _db_update_phase_callback(phase):
print(f"🔄 [DB Phase] {phase}")
with db_update_lock:
db_update_state["phase"] = phase
@ -5635,6 +5637,8 @@ def _run_db_update_task(full_refresh, server_type):
media_client = plex_client
elif server_type == "jellyfin":
media_client = jellyfin_client
elif server_type == "navidrome":
media_client = navidrome_client
if not media_client:
_db_update_error_callback(f"Media client for '{server_type}' not available.")
@ -5644,7 +5648,8 @@ def _run_db_update_task(full_refresh, server_type):
db_update_worker = DatabaseUpdateWorker(
media_client=media_client,
full_refresh=full_refresh,
server_type=server_type
server_type=server_type,
force_sequential=True # Force sequential processing in web server mode
)
# Connect signals to callbacks (handle both Qt and headless modes)
try:
@ -5908,6 +5913,9 @@ def start_database_update():
def get_database_update_status():
"""Endpoint to poll for the current update status."""
with db_update_lock:
# Debug: Log current state occasionally
if db_update_state["status"] == "running":
print(f"📊 [Status Check] {db_update_state['processed']}/{db_update_state['total']} ({db_update_state['progress']:.1f}%) - {db_update_state['phase']}")
return jsonify(db_update_state)
@app.route('/api/database/update/stop', methods=['POST'])

View file

@ -6874,6 +6874,7 @@ function stopDbStatsPolling() {
function stopDbUpdatePolling() {
if (dbUpdateStatusInterval) {
console.log('⏹️ Stopping database update polling');
clearInterval(dbUpdateStatusInterval);
dbUpdateStatusInterval = null;
}
@ -7155,20 +7156,24 @@ async function checkForAutoInitiatedWishlistProcess() {
async function checkAndUpdateDbProgress() {
try {
const response = await fetch('/api/database/update/status');
const response = await fetch('/api/database/update/status', {
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) return;
const state = await response.json();
console.debug('📊 DB Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`);
updateDbProgressUI(state);
if (state.status === 'running') {
// If an update is running, start polling for progress
stopDbUpdatePolling();
// Start polling only if not already polling and status is running
if (state.status === 'running' && !dbUpdateStatusInterval) {
console.log('🔄 Starting database update polling (1 second interval)');
dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000);
}
} catch (error) {
console.warn('Could not fetch DB update status:', error);
// Don't stop polling on network errors - keep trying
}
}