diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0e54c471..1ccfb1cd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -11,6 +11,7 @@ on: branches: - main - master + default: '2.3' jobs: build-and-push: diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py index bd204c3f..16d665ed 100644 --- a/core/audiodb_worker.py +++ b/core/audiodb_worker.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.audiodb_client import AudioDBClient +from core.worker_utils import interruptible_sleep logger = get_logger("audiodb_worker") @@ -24,6 +25,7 @@ class AudioDBWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -52,6 +54,7 @@ class AudioDBWorker: self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("AudioDB background worker started") @@ -64,9 +67,10 @@ class AudioDBWorker: logger.info("Stopping AudioDB worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("AudioDB worker stopped") @@ -115,7 +119,7 @@ class AudioDBWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue self.current_item = None @@ -124,7 +128,7 @@ class AudioDBWorker: if not item: logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item @@ -143,11 +147,11 @@ class AudioDBWorker: self._process_item(item) - time.sleep(2) + interruptible_sleep(self._stop_event, 2) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) logger.info("AudioDB worker thread finished") diff --git a/core/deezer_client.py b/core/deezer_client.py index 5b89f868..e94ebbc3 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -72,9 +72,21 @@ class Track: if isinstance(album_data, dict): album_image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium') - # Get artist name + # Get artist name(s) — use contributors for multi-artist tracks (feat. collabs) artist_data = track_data.get('artist', {}) artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist' + contributors = track_data.get('contributors', []) + if isinstance(contributors, list) and len(contributors) > 1: + artist_names = [] + for c in contributors: + if isinstance(c, dict) and c.get('name'): + artist_names.append(c['name']) + if artist_names: + all_artists = artist_names + else: + all_artists = [artist_name] + else: + all_artists = [artist_name] # Get album name album_name = '' @@ -102,7 +114,7 @@ class Track: return cls( id=str(track_data.get('id', '')), name=track_data.get('title', ''), - artists=[artist_name], + artists=all_artists, album=album_name, duration_ms=track_data.get('duration', 0) * 1000, # Deezer returns seconds popularity=track_data.get('rank', 0), @@ -416,6 +428,15 @@ class DeezerClient: album_name = album_data.get('title', '') if isinstance(album_data, dict) else str(album_data) if album_data else '' album_id = str(album_data.get('id', '')) if isinstance(album_data, dict) else '' + # Use contributors for multi-artist tracks + contributors = track_data.get('contributors', []) + if isinstance(contributors, list) and len(contributors) > 1: + all_artists = [c['name'] for c in contributors if isinstance(c, dict) and c.get('name')] + if not all_artists: + all_artists = [artist_name] + else: + all_artists = [artist_name] + return { 'id': str(track_data.get('id', '')), 'name': track_data.get('title', ''), @@ -423,7 +444,7 @@ class DeezerClient: 'disc_number': track_data.get('disk_number', 1), 'duration_ms': track_data.get('duration', 0) * 1000, 'explicit': track_data.get('explicit_lyrics', False), - 'artists': [artist_name], + 'artists': all_artists, 'primary_artist': artist_name, 'album': { 'id': album_id, diff --git a/core/deezer_worker.py b/core/deezer_worker.py index 9e216f06..b70ea57a 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.deezer_client import DeezerClient +from core.worker_utils import interruptible_sleep logger = get_logger("deezer_worker") @@ -24,6 +25,7 @@ class DeezerWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -52,6 +54,7 @@ class DeezerWorker: self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Deezer background worker started") @@ -64,9 +67,10 @@ class DeezerWorker: logger.info("Stopping Deezer worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Deezer worker stopped") @@ -115,7 +119,7 @@ class DeezerWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue self.current_item = None @@ -124,7 +128,7 @@ class DeezerWorker: if not item: logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item @@ -143,11 +147,11 @@ class DeezerWorker: self._process_item(item) - time.sleep(2) + interruptible_sleep(self._stop_event, 2) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) logger.info("Deezer worker thread finished") diff --git a/core/discogs_worker.py b/core/discogs_worker.py index 6bcdda31..98112c94 100644 --- a/core/discogs_worker.py +++ b/core/discogs_worker.py @@ -18,6 +18,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.discogs_client import DiscogsClient +from core.worker_utils import interruptible_sleep logger = get_logger("discogs_worker") @@ -34,6 +35,7 @@ class DiscogsWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -62,6 +64,7 @@ class DiscogsWorker: self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Discogs background worker started") @@ -73,8 +76,9 @@ class DiscogsWorker: logger.info("Stopping Discogs worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Discogs worker stopped") def pause(self): @@ -113,14 +117,14 @@ class DiscogsWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue self.current_item = None item = self._get_next_item() if not item: - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item.get('name', '') @@ -132,11 +136,11 @@ class DiscogsWorker: continue self._process_item(item) - time.sleep(2) + interruptible_sleep(self._stop_event, 2) except Exception as e: logger.error(f"Error in Discogs worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) logger.info("Discogs worker thread finished") diff --git a/core/genius_worker.py b/core/genius_worker.py index f3468b71..63de47a6 100644 --- a/core/genius_worker.py +++ b/core/genius_worker.py @@ -9,6 +9,7 @@ from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.genius_client import GeniusClient from config.settings import config_manager +from core.worker_utils import interruptible_sleep logger = get_logger("genius_worker") @@ -31,6 +32,7 @@ class GeniusWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -64,6 +66,7 @@ class GeniusWorker: self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Genius background worker started") @@ -76,9 +79,10 @@ class GeniusWorker: logger.info("Stopping Genius worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Genius worker stopped") @@ -123,14 +127,14 @@ class GeniusWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue # Check if access token is configured if not self.client.access_token: self._init_client() if not self.client.access_token: - time.sleep(30) + interruptible_sleep(self._stop_event, 30) continue self.current_item = None @@ -138,7 +142,7 @@ class GeniusWorker: if not item: logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item @@ -157,11 +161,11 @@ class GeniusWorker: self._process_item(item) # Genius rate limiting is conservative (500ms per call) + lyrics scraping - time.sleep(1) + interruptible_sleep(self._stop_event, 1) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) logger.info("Genius worker thread finished") diff --git a/core/hydrabase_worker.py b/core/hydrabase_worker.py index f6e44c6b..99b30a1b 100644 --- a/core/hydrabase_worker.py +++ b/core/hydrabase_worker.py @@ -15,6 +15,7 @@ import time import uuid logger = logging.getLogger(__name__) +from core.worker_utils import interruptible_sleep class HydrabaseWorker: @@ -31,6 +32,7 @@ class HydrabaseWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Queue with cap self.queue = queue.Queue(maxsize=1000) @@ -47,6 +49,7 @@ class HydrabaseWorker: return self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Hydrabase P2P mirror worker started") @@ -56,8 +59,9 @@ class HydrabaseWorker: return self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Hydrabase P2P mirror worker stopped") def pause(self): @@ -102,7 +106,7 @@ class HydrabaseWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue # Non-blocking dequeue with timeout @@ -112,12 +116,12 @@ class HydrabaseWorker: continue self._process_item(item) - time.sleep(0.5) # Rate limit + interruptible_sleep(self._stop_event, 0.5) # Rate limit except Exception as e: logger.error(f"Error in Hydrabase worker loop: {e}") self.stats['errors'] += 1 - time.sleep(2) + interruptible_sleep(self._stop_event, 2) def _process_item(self, item): ws, lock = self.get_ws_and_lock() diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 723ce5b6..9a8bd085 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.itunes_client import iTunesClient +from core.worker_utils import interruptible_sleep logger = get_logger("itunes_worker") @@ -34,6 +35,7 @@ class iTunesWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -66,6 +68,7 @@ class iTunesWorker: return self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("iTunes background worker started") @@ -76,8 +79,9 @@ class iTunesWorker: logger.info("Stopping iTunes worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("iTunes worker stopped") def pause(self): @@ -116,7 +120,7 @@ class iTunesWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue # No auth check needed — iTunes API requires no authentication @@ -126,7 +130,7 @@ class iTunesWorker: if not item: logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item @@ -147,13 +151,13 @@ class iTunesWorker: # Sleep depends on item type — search items need more delay item_type = item.get('type', '') if item_type in ('album_batch', 'track_batch'): - time.sleep(self.batch_inter_item_sleep) + interruptible_sleep(self._stop_event, self.batch_inter_item_sleep) else: - time.sleep(self.inter_item_sleep) + interruptible_sleep(self._stop_event, self.inter_item_sleep) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) self.current_item = None logger.info("iTunes worker thread finished") @@ -444,7 +448,7 @@ class iTunesWorker: self._mark_status('album', db_id, 'not_found') self.stats['not_found'] += 1 - time.sleep(self.batch_inter_item_sleep) + interruptible_sleep(self._stop_event, self.batch_inter_item_sleep) logger.info(f"Album batch for '{artist_name}': {matched_count}/{len(db_albums)} matched") @@ -516,7 +520,7 @@ class iTunesWorker: self._mark_status('track', db_id, 'not_found') self.stats['not_found'] += 1 - time.sleep(self.batch_inter_item_sleep) + interruptible_sleep(self._stop_event, self.batch_inter_item_sleep) logger.info(f"Track batch for '{album_name}': {matched_count}/{len(db_tracks)} matched") diff --git a/core/lastfm_worker.py b/core/lastfm_worker.py index 4005e593..6c0f4de3 100644 --- a/core/lastfm_worker.py +++ b/core/lastfm_worker.py @@ -9,6 +9,7 @@ from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.lastfm_client import LastFMClient from config.settings import config_manager +from core.worker_utils import interruptible_sleep logger = get_logger("lastfm_worker") @@ -31,6 +32,7 @@ class LastFMWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -65,6 +67,7 @@ class LastFMWorker: self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Last.fm background worker started") @@ -77,9 +80,10 @@ class LastFMWorker: logger.info("Stopping Last.fm worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Last.fm worker stopped") @@ -124,14 +128,14 @@ class LastFMWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue # Check if API key is configured if not self.client.api_key: self._init_client() if not self.client.api_key: - time.sleep(30) + interruptible_sleep(self._stop_event, 30) continue self.current_item = None @@ -139,7 +143,7 @@ class LastFMWorker: if not item: logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item @@ -158,11 +162,11 @@ class LastFMWorker: self._process_item(item) # Last.fm allows 5 req/sec but we use multiple calls per item - time.sleep(1) + interruptible_sleep(self._stop_event, 1) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) logger.info("Last.fm worker thread finished") diff --git a/core/listening_stats_worker.py b/core/listening_stats_worker.py index 5710db18..414f857a 100644 --- a/core/listening_stats_worker.py +++ b/core/listening_stats_worker.py @@ -11,6 +11,7 @@ import time from typing import Dict, Any from utils.logging_config import get_logger +from core.worker_utils import interruptible_sleep logger = get_logger("listening_stats_worker") @@ -32,6 +33,7 @@ class ListeningStatsWorker: self.should_stop = False self.thread = None self.current_item = None + self._stop_event = threading.Event() # Stats self.stats = { @@ -52,6 +54,7 @@ class ListeningStatsWorker: return self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Listening stats worker started") @@ -61,8 +64,9 @@ class ListeningStatsWorker: return self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Listening stats worker stopped") def pause(self): @@ -86,25 +90,30 @@ class ListeningStatsWorker: logger.info("Listening stats worker thread started") # Build cache from existing data immediately (before first poll) - time.sleep(5) + if interruptible_sleep(self._stop_event, 5): + return try: self._build_stats_cache() logger.info("Initial stats cache built from existing data") except Exception as e: logger.debug(f"Initial cache build skipped: {e}") + if self.should_stop: + return + # Wait before first poll - time.sleep(10) + if interruptible_sleep(self._stop_event, 10): + return while not self.should_stop: try: if self.paused: - time.sleep(5) + interruptible_sleep(self._stop_event, 5) continue # Check if enabled if not self.config_manager.get('listening_stats.enabled', True): - time.sleep(30) + interruptible_sleep(self._stop_event, 30) continue # Update poll interval from config @@ -119,12 +128,13 @@ class ListeningStatsWorker: for _ in range(int(self.poll_interval)): if self.should_stop: break - time.sleep(1) + if interruptible_sleep(self._stop_event, 1): + break except Exception as e: logger.error(f"Error in listening stats worker: {e}", exc_info=True) self.stats['errors'] += 1 - time.sleep(60) + interruptible_sleep(self._stop_event, 60) self.current_item = None logger.info("Listening stats worker thread finished") diff --git a/core/matching_engine.py b/core/matching_engine.py index 83ed1c09..586914c5 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -657,22 +657,30 @@ class MusicMatchingEngine: # --- Source Type --- is_youtube = slskd_track.username == 'youtube' - # 4b. Album Bonus: Prefer results from the correct album folder - # This ensures "Dark Side of the Moon" sources rank above "Greatest Hits" - # when both have the same song. Binary check — album words in path or not. + # 4b. Album Bonus/Penalty: Prefer results from the correct album folder. + # Uses full-string similarity to prevent "Paradise" matching "Club Paradise". + # The old subset check said "paradise" ⊂ {"club", "paradise"} = True, which was wrong. album_bonus = 0.0 album_name = getattr(spotify_track, 'album', None) if album_name and not is_youtube: - album_words = set(self.clean_album_name(album_name).split()) - if album_words: + album_cleaned = self.clean_album_name(album_name) + if album_cleaned: + best_album_sim = 0.0 path_segments = re.split(r'[/\\]', slskd_track.filename) for segment in path_segments: if not segment: continue - seg_words = set(self.normalize_string(segment).split()) - if album_words.issubset(seg_words): - album_bonus = 0.10 - break + seg_cleaned = self.normalize_string(segment) + if not seg_cleaned: + continue + sim = SequenceMatcher(None, album_cleaned, seg_cleaned).ratio() + best_album_sim = max(best_album_sim, sim) + + if best_album_sim >= 0.85: + album_bonus = 0.10 # Strong album match (e.g. "Paradise" vs "Paradise") + elif best_album_sim >= 0.60: + album_bonus = 0.03 # Partial match — small bonus + # No penalty for low similarity — the file might just not have album folders # 5. Special handling for short titles (high false positive risk) # Titles like "Run", "Love", "Girls", "Stay" need stricter artist matching diff --git a/core/media_scan_manager.py b/core/media_scan_manager.py index 28f083b3..f7661128 100644 --- a/core/media_scan_manager.py +++ b/core/media_scan_manager.py @@ -39,6 +39,7 @@ class MediaScanManager: 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") @@ -119,6 +120,9 @@ class MediaScanManager: """ 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 @@ -134,6 +138,7 @@ class MediaScanManager: # 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): @@ -164,6 +169,9 @@ class MediaScanManager: 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 @@ -211,6 +219,7 @@ class MediaScanManager: # 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: @@ -247,15 +256,22 @@ class MediaScanManager: 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() @@ -360,6 +376,7 @@ class MediaScanManager: 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 @@ -369,4 +386,4 @@ class MediaScanManager: self._periodic_update_timer = None self._is_doing_periodic_updates = False - logger.info("MediaScanManager shutdown - cancelled all pending timers") \ No newline at end of file + logger.info("MediaScanManager shutdown - cancelled all pending timers") diff --git a/core/metadata_cache.py b/core/metadata_cache.py index 5f468124..50d547de 100644 --- a/core/metadata_cache.py +++ b/core/metadata_cache.py @@ -99,7 +99,13 @@ class MetadataCache: def _is_junk_entity(self, fields: dict) -> bool: """Check if extracted fields represent junk/placeholder data.""" name = (fields.get('name') or '').strip().lower() - return name in self._JUNK_NAMES + if name in self._JUNK_NAMES: + return True + # For tracks: reject if artist_name is junk (prevents caching "Song by Unknown Artist") + artist_name = (fields.get('artist_name') or '').strip().lower() + if artist_name and artist_name in self._JUNK_NAMES: + return True + return False def store_entity(self, source: str, entity_type: str, entity_id: str, raw_data: dict) -> None: """Store an entity in the cache. Extracts structured fields from raw_data.""" diff --git a/core/musicbrainz_worker.py b/core/musicbrainz_worker.py index ccdd7108..79496d2b 100644 --- a/core/musicbrainz_worker.py +++ b/core/musicbrainz_worker.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.musicbrainz_service import MusicBrainzService +from core.worker_utils import interruptible_sleep logger = get_logger("musicbrainz_worker") @@ -20,6 +21,7 @@ class MusicBrainzWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -45,6 +47,7 @@ class MusicBrainzWorker: self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("MusicBrainz background worker started") @@ -57,9 +60,10 @@ class MusicBrainzWorker: logger.info("Stopping MusicBrainz worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Music Brainz worker stopped") @@ -112,7 +116,7 @@ class MusicBrainzWorker: try: # Check if paused if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue # Clear previous item before getting next @@ -124,7 +128,7 @@ class MusicBrainzWorker: if not item: # No more items - sleep for a bit logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue # Set current item for UI tracking @@ -147,11 +151,11 @@ class MusicBrainzWorker: # Keep current_item set during sleep so UI can see what was just processed # Rate limit: 1 request per second - time.sleep(1) + interruptible_sleep(self._stop_event, 1) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) # Back off on errors + interruptible_sleep(self._stop_event, 5) # Back off on errors logger.info("MusicBrainz worker thread finished") diff --git a/core/plex_client.py b/core/plex_client.py index 0fdea25f..8c68fc74 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -277,13 +277,13 @@ class PlexClient: try: for playlist in self.server.playlists(): - if playlist.playlistType == 'audio': + if getattr(playlist, 'playlistType', None) == 'audio': playlist_info = PlexPlaylistInfo.from_plex_playlist(playlist) playlists.append(playlist_info) - + logger.info(f"Retrieved {len(playlists)} audio playlists") return playlists - + except Exception as e: logger.error(f"Error fetching playlists: {e}") return [] @@ -294,7 +294,7 @@ class PlexClient: try: playlist = self.server.playlist(name) - if playlist.playlistType == 'audio': + if getattr(playlist, 'playlistType', None) == 'audio': return PlexPlaylistInfo.from_plex_playlist(playlist) return None diff --git a/core/plex_scan_manager.py b/core/plex_scan_manager.py index 8c694284..fd8af490 100644 --- a/core/plex_scan_manager.py +++ b/core/plex_scan_manager.py @@ -39,6 +39,7 @@ class PlexScanManager: 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") @@ -51,6 +52,9 @@ class PlexScanManager: """ 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 @@ -66,6 +70,7 @@ class PlexScanManager: # 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): @@ -96,6 +101,9 @@ class PlexScanManager: 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 @@ -136,6 +144,7 @@ class PlexScanManager: # 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: @@ -165,15 +174,22 @@ class PlexScanManager: 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() @@ -218,7 +234,9 @@ class PlexScanManager: if is_scanning: # Still scanning, poll again in 30 seconds logger.info("DEBUG: Plex library still scanning, will check again in 30 seconds") - threading.Timer(30, self._poll_scan_status).start() + 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 @@ -311,6 +329,7 @@ class PlexScanManager: 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 @@ -320,4 +339,4 @@ class PlexScanManager: self._periodic_update_timer = None self._is_doing_periodic_updates = False - logger.info("PlexScanManager shutdown - cancelled all pending timers") \ No newline at end of file + logger.info("PlexScanManager shutdown - cancelled all pending timers") diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index bf54c4e8..d47b58b4 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.qobuz_client import _qobuz_is_rate_limited +from core.worker_utils import interruptible_sleep logger = get_logger("qobuz_worker") @@ -24,6 +25,7 @@ class QobuzWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -52,6 +54,7 @@ class QobuzWorker: self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Qobuz background worker started") @@ -64,9 +67,10 @@ class QobuzWorker: logger.info("Stopping Qobuz worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Qobuz worker stopped") @@ -120,24 +124,24 @@ class QobuzWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue # Auth guard: sleep if not authenticated try: if not self.client or not self.client.is_authenticated(): self.current_item = None - time.sleep(30) + interruptible_sleep(self._stop_event, 30) continue except Exception: - time.sleep(30) + interruptible_sleep(self._stop_event, 30) continue # Rate limit guard: back off if globally rate limited if _qobuz_is_rate_limited(): self.current_item = None logger.debug("Qobuz rate limited, backing off...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = None @@ -146,7 +150,7 @@ class QobuzWorker: if not item: logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item @@ -166,11 +170,11 @@ class QobuzWorker: self._process_item(item) # Throttle between API calls - time.sleep(2) + interruptible_sleep(self._stop_event, 2) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) logger.info("Qobuz worker thread finished") @@ -351,7 +355,7 @@ class QobuzWorker: error_str = str(e).lower() if '429' in error_str or 'rate limit' in error_str: logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, backing off 30s") - time.sleep(30) + interruptible_sleep(self._stop_event, 30) return logger.error(f"Error processing {item['type']} #{item['id']}: {e}") self.stats['errors'] += 1 diff --git a/core/repair_jobs/acoustid_scanner.py b/core/repair_jobs/acoustid_scanner.py index dad6ae60..560cdd64 100644 --- a/core/repair_jobs/acoustid_scanner.py +++ b/core/repair_jobs/acoustid_scanner.py @@ -141,7 +141,8 @@ class AcoustIDScannerJob(RepairJob): if batch_count >= batch_size: batch_count = 0 self._save_checkpoint(context, fpath) - time.sleep(2) + if context.sleep_or_stop(2): + return result if context.update_progress and (i + 1) % 10 == 0: context.update_progress(i + 1, total) diff --git a/core/repair_jobs/base.py b/core/repair_jobs/base.py index 41178780..f07b6019 100644 --- a/core/repair_jobs/base.py +++ b/core/repair_jobs/base.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field +import threading from typing import Any, Callable, Dict, List, Optional @@ -29,6 +30,7 @@ class JobContext: mb_client: Any = None acoustid_client: Any = None metadata_cache: Any = None + stop_event: Optional[threading.Event] = None # Callbacks create_finding: Optional[Callable] = None @@ -39,6 +41,8 @@ class JobContext: def check_stop(self) -> bool: """Return True if the worker should stop.""" + if self.stop_event and self.stop_event.is_set(): + return True return self.should_stop() if self.should_stop else False def is_spotify_rate_limited(self) -> bool: @@ -55,11 +59,31 @@ class JobContext: def wait_if_paused(self): """Block until unpaused or stopped. Returns True if should stop.""" - import time while self.is_paused and self.is_paused(): if self.check_stop(): return True - time.sleep(1) + if self.stop_event: + self.stop_event.wait(0.2) + else: + import time + time.sleep(0.2) + return self.check_stop() + + def sleep_or_stop(self, seconds: float, step: float = 0.2) -> bool: + """Sleep in small increments so stop requests can interrupt quickly.""" + if seconds <= 0: + return self.check_stop() + remaining = seconds + while remaining > 0: + if self.check_stop(): + return True + chunk = min(step, remaining) + if self.stop_event: + self.stop_event.wait(chunk) + else: + import time + time.sleep(chunk) + remaining -= chunk return self.check_stop() diff --git a/core/repair_jobs/library_reorganize.py b/core/repair_jobs/library_reorganize.py index 8c6debe0..68afd5bd 100644 --- a/core/repair_jobs/library_reorganize.py +++ b/core/repair_jobs/library_reorganize.py @@ -821,7 +821,8 @@ class LibraryReorganizeJob(RepairJob): years[key] = year_str break import time - time.sleep(0.1) # Rate limit courtesy + if context.sleep_or_stop(0.1): # Rate limit courtesy + break except Exception as e: logger.debug("API year lookup failed for %s - %s: %s", artist, album, e) diff --git a/core/repair_jobs/mbid_mismatch_detector.py b/core/repair_jobs/mbid_mismatch_detector.py index 465de1a7..51d89939 100644 --- a/core/repair_jobs/mbid_mismatch_detector.py +++ b/core/repair_jobs/mbid_mismatch_detector.py @@ -294,7 +294,8 @@ class MbidMismatchDetectorJob(RepairJob): try: # Rate limit: MusicBrainz allows ~1 req/sec - time.sleep(1.1) + if context.sleep_or_stop(1.1): + return result recording = mb_client.get_recording(mbid, includes=['artist-credits']) if not recording: diff --git a/core/repair_jobs/metadata_gap_filler.py b/core/repair_jobs/metadata_gap_filler.py index b2186ded..323f7522 100644 --- a/core/repair_jobs/metadata_gap_filler.py +++ b/core/repair_jobs/metadata_gap_filler.py @@ -173,7 +173,8 @@ class MetadataGapFillerJob(RepairJob): # Rate limit API calls if spotify_track_id: - time.sleep(0.5) + if context.sleep_or_stop(0.5): + return result if context.update_progress and (i + 1) % 10 == 0: context.update_progress(i + 1, total) diff --git a/core/repair_jobs/unknown_artist_fixer.py b/core/repair_jobs/unknown_artist_fixer.py index 353039fa..56de6284 100644 --- a/core/repair_jobs/unknown_artist_fixer.py +++ b/core/repair_jobs/unknown_artist_fixer.py @@ -337,7 +337,8 @@ class UnknownArtistFixerJob(RepairJob): except Exception as e: logger.debug(f"Title search failed for '{title}': {e}") # Rate limit courtesy - time.sleep(0.2) + if context.sleep_or_stop(0.2): + return None return None diff --git a/core/repair_worker.py b/core/repair_worker.py index ff3bd531..305c1fb9 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -67,6 +67,7 @@ class RepairWorker: self.running = False self.enabled = False # Master toggle (replaces 'paused') self.should_stop = False + self._stop_event = threading.Event() self.thread = None # Current job being executed @@ -293,6 +294,7 @@ class RepairWorker: return self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Repair worker started") @@ -303,8 +305,9 @@ class RepairWorker: logger.info("Stopping repair worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=2) logger.info("Repair worker stopped") def toggle(self) -> bool: @@ -418,7 +421,7 @@ class RepairWorker: logger.info("Repair worker thread started") self._ensure_jobs_loaded() - while not self.should_stop: + while not self._stop_event.is_set(): try: # Check force-run queue even when disabled (user explicitly requested) forced_job = None @@ -428,13 +431,15 @@ class RepairWorker: if forced_job: self._run_job(forced_job) - time.sleep(2) + if self._sleep_or_stop(2): + break continue if not self.enabled: self._current_job_id = None self._current_job_name = None - time.sleep(2) + if self._sleep_or_stop(2): + break continue # Find the next job to run based on staleness @@ -444,20 +449,23 @@ class RepairWorker: # Nothing due — sleep and re-check self._current_job_id = None self._current_job_name = None - time.sleep(10) + if self._sleep_or_stop(10): + break continue # Run the selected job self._run_job(next_job) # Brief pause between jobs - time.sleep(5) + if self._sleep_or_stop(5): + break except Exception as e: logger.error("Error in repair worker loop: %s", e, exc_info=True) self._current_job_id = None self._current_job_name = None - time.sleep(30) + if self._sleep_or_stop(30): + break logger.info("Repair worker thread finished") @@ -552,6 +560,7 @@ class RepairWorker: metadata_cache=self.metadata_cache, create_finding=self._create_finding, should_stop=lambda: self.should_stop, + stop_event=self._stop_event, is_paused=lambda: not self.enabled, update_progress=self._update_progress, report_progress=_report_progress, @@ -595,6 +604,17 @@ class RepairWorker: self._current_job_name = None self._current_progress = {'scanned': 0, 'total': 0, 'percent': 0} + def _sleep_or_stop(self, seconds: float, step: float = 0.2) -> bool: + """Sleep in small chunks so shutdown interrupts quickly.""" + if seconds <= 0: + return self._stop_event.is_set() + remaining = seconds + while remaining > 0 and not self._stop_event.is_set(): + chunk = min(step, remaining) + self._stop_event.wait(chunk) + remaining -= chunk + return self._stop_event.is_set() + def run_job_now(self, job_id: str): """Queue a job for immediate execution by the main worker loop. diff --git a/core/soulid_worker.py b/core/soulid_worker.py index aeebd4a9..cc800460 100644 --- a/core/soulid_worker.py +++ b/core/soulid_worker.py @@ -24,6 +24,7 @@ import unicodedata from typing import Dict, Any, List, Optional from utils.logging_config import get_logger +from core.worker_utils import interruptible_sleep logger = get_logger("soulid_worker") @@ -79,6 +80,7 @@ class SoulIDWorker: self.should_stop = False self.thread = None self.current_item = None + self._stop_event = threading.Event() # API clients (lazy-initialized) self._itunes_client = None @@ -135,6 +137,7 @@ class SoulIDWorker: return self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("SoulID worker started") @@ -144,8 +147,9 @@ class SoulIDWorker: return self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("SoulID worker stopped") def pause(self): @@ -178,7 +182,7 @@ class SoulIDWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue processed = 0 @@ -188,16 +192,16 @@ class SoulIDWorker: if processed == 0: self.current_item = None - time.sleep(self.idle_sleep) + interruptible_sleep(self._stop_event, self.idle_sleep) else: # Albums/tracks get inter_batch_sleep, artists get their # own sleep inside _process_next_artist - time.sleep(self.inter_batch_sleep) + interruptible_sleep(self._stop_event, self.inter_batch_sleep) except Exception as e: logger.error(f"Error in SoulID worker loop: {e}", exc_info=True) self.stats['errors'] += 1 - time.sleep(5) + interruptible_sleep(self._stop_event, 5) self.current_item = None logger.info("SoulID worker thread finished") @@ -267,7 +271,7 @@ class SoulIDWorker: logger.info(f"Generated soul ID for artist: {name}" + (f" (canonical id: {canonical_id})" if canonical_id else "")) # Rate limit courtesy for API calls - time.sleep(self.artist_sleep) + interruptible_sleep(self._stop_event, self.artist_sleep) return 1 except Exception as e: @@ -324,7 +328,7 @@ class SoulIDWorker: deezer_artist_id = int(raw_id) logger.debug(f"Deezer artist ID for '{artist_name}': {deezer_artist_id}") break - time.sleep(0.3) + interruptible_sleep(self._stop_event, 0.3) except Exception as e: logger.debug(f"Deezer track search failed for '{artist_name}': {e}") @@ -344,7 +348,7 @@ class SoulIDWorker: itunes_artist_id = int(raw_id) logger.debug(f"iTunes artist ID for '{artist_name}': {itunes_artist_id}") break - time.sleep(0.3) + interruptible_sleep(self._stop_event, 0.3) except Exception as e: logger.debug(f"iTunes track search failed for '{artist_name}': {e}") diff --git a/core/spotify_worker.py b/core/spotify_worker.py index ff7ac832..8b6c00b4 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -8,6 +8,7 @@ from datetime import datetime, date, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.spotify_client import SpotifyClient, SpotifyRateLimitError +from core.worker_utils import interruptible_sleep logger = get_logger("spotify_worker") @@ -31,6 +32,7 @@ class SpotifyWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -66,6 +68,7 @@ class SpotifyWorker: return self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Spotify background worker started") @@ -76,8 +79,9 @@ class SpotifyWorker: logger.info("Stopping Spotify worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Spotify worker stopped") def pause(self): @@ -167,7 +171,7 @@ class SpotifyWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue # Rate limit guard — if globally rate limited, sleep until ban expires @@ -175,7 +179,7 @@ class SpotifyWorker: info = self.client.get_rate_limit_info() remaining = info['remaining_seconds'] if info else 60 logger.debug(f"Spotify globally rate limited, sleeping {remaining}s...") - time.sleep(min(remaining, 60)) # Check again every 60s max + interruptible_sleep(self._stop_event, min(remaining, 60)) # Check again every 60s max continue # Daily budget guard — worker-only cap to avoid saturating Spotify rate limits @@ -184,7 +188,7 @@ class SpotifyWorker: resets_in = budget['resets_in_seconds'] logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), " f"resets in {resets_in // 3600}h {(resets_in % 3600) // 60}m") - time.sleep(min(resets_in, 300)) # Check every 5 min max + interruptible_sleep(self._stop_event, min(resets_in, 300)) # Check every 5 min max continue # Post-ban cooldown guard — after ban expires, wait before resuming @@ -192,7 +196,7 @@ class SpotifyWorker: cooldown = self.client.get_post_ban_cooldown_remaining() if cooldown > 0: logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...") - time.sleep(min(cooldown, 60)) + interruptible_sleep(self._stop_event, min(cooldown, 60)) continue # Auth guard — check if Spotify client is configured (no API call). @@ -203,7 +207,7 @@ class SpotifyWorker: self.client.reload_config() if not self.client.is_spotify_authenticated(): logger.debug("Spotify not authenticated, sleeping 30s...") - time.sleep(30) + interruptible_sleep(self._stop_event, 30) continue self.current_item = None @@ -211,7 +215,7 @@ class SpotifyWorker: if not item: logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item @@ -229,14 +233,14 @@ class SpotifyWorker: self._process_item(item) self._increment_daily_budget() - time.sleep(self.inter_item_sleep) + interruptible_sleep(self._stop_event, self.inter_item_sleep) except SpotifyRateLimitError: logger.debug("Spotify rate limit hit in worker loop, will retry after ban expires") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) self.current_item = None logger.info("Spotify worker thread finished") @@ -542,7 +546,7 @@ class SpotifyWorker: self._mark_status('album', db_id, 'not_found') self.stats['not_found'] += 1 - time.sleep(self.batch_inter_item_sleep) + interruptible_sleep(self._stop_event, self.batch_inter_item_sleep) logger.info(f"Album batch for '{artist_name}': {matched_count}/{len(db_albums)} matched") @@ -615,7 +619,7 @@ class SpotifyWorker: self._mark_status('track', db_id, 'not_found') self.stats['not_found'] += 1 - time.sleep(self.batch_inter_item_sleep) + interruptible_sleep(self._stop_event, self.batch_inter_item_sleep) logger.info(f"Track batch for '{album_name}': {matched_count}/{len(db_tracks)} matched") diff --git a/core/tidal_worker.py b/core/tidal_worker.py index c02e398a..097227fc 100644 --- a/core/tidal_worker.py +++ b/core/tidal_worker.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.tidal_client import TidalClient +from core.worker_utils import interruptible_sleep logger = get_logger("tidal_worker") @@ -45,6 +46,7 @@ class TidalWorker: self.paused = False self.should_stop = False self.thread = None + self._stop_event = threading.Event() # Current item being processed (for UI tooltip) self.current_item = None @@ -73,6 +75,7 @@ class TidalWorker: self.running = True self.should_stop = False + self._stop_event.clear() self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() logger.info("Tidal background worker started") @@ -85,9 +88,10 @@ class TidalWorker: logger.info("Stopping Tidal worker...") self.should_stop = True self.running = False + self._stop_event.set() if self.thread: - self.thread.join(timeout=5) + self.thread.join(timeout=1) logger.info("Tidal worker stopped") @@ -140,17 +144,17 @@ class TidalWorker: while not self.should_stop: try: if self.paused: - time.sleep(1) + interruptible_sleep(self._stop_event, 1) continue # Auth guard: sleep if not authenticated try: if not self.client.is_authenticated(): self.current_item = None - time.sleep(30) + interruptible_sleep(self._stop_event, 30) continue except Exception: - time.sleep(30) + interruptible_sleep(self._stop_event, 30) continue self.current_item = None @@ -159,7 +163,7 @@ class TidalWorker: if not item: logger.debug("No pending items, sleeping...") - time.sleep(10) + interruptible_sleep(self._stop_event, 10) continue self.current_item = item @@ -178,11 +182,11 @@ class TidalWorker: self._process_item(item) - time.sleep(2) + interruptible_sleep(self._stop_event, 2) except Exception as e: logger.error(f"Error in worker loop: {e}") - time.sleep(5) + interruptible_sleep(self._stop_event, 5) logger.info("Tidal worker thread finished") @@ -364,7 +368,7 @@ class TidalWorker: if '429' in error_str or 'rate limit' in error_str: # Rate limit — don't mark as error, back off then retry logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, backing off 30s") - time.sleep(30) + interruptible_sleep(self._stop_event, 30) return logger.error(f"Error processing {item['type']} #{item['id']}: {e}") self.stats['errors'] += 1 diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index a841a71d..dedc93f2 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -807,6 +807,12 @@ class WatchlistScanner: logger.debug(f"Skipping {release_type}: {album_data.get('name', 'Unknown')} - user preference") continue + # Skip albums with placeholder track names (unreleased tracklist) + # Spotify uses "Track 1", "Track 2", etc. for unannounced tracks + if self._has_placeholder_tracks(tracks): + logger.info(f"Skipping album with placeholder tracks (unreleased tracklist): {album_data.get('name', 'Unknown')}") + continue + # Check each track for track in tracks: # Check content type filters (live, remix, acoustic, compilation) @@ -1349,6 +1355,22 @@ class WatchlistScanner: except Exception: return False # Error — assume released + def _has_placeholder_tracks(self, tracks: list) -> bool: + """Check if an album's tracks are mostly placeholders (unreleased/unannounced tracklist). + Spotify uses 'Track 1', 'Track 2', etc. for tracks whose names haven't been revealed.""" + if not tracks or len(tracks) == 0: + return False + import re + placeholder_count = 0 + for track in tracks: + name = track.get('name', '') if isinstance(track, dict) else getattr(track, 'name', '') + # Match "Track 1", "Track 2", ..., "Track 99" (case-insensitive) + if re.match(r'^track\s+\d+$', name.strip(), re.IGNORECASE): + placeholder_count += 1 + # If more than half the tracks are placeholders, skip the album + # (some albums legitimately have a track called "Track X" but not most of them) + return placeholder_count > len(tracks) / 2 + def _should_include_release(self, track_count: int, watchlist_artist: WatchlistArtist) -> bool: """ Check if a release should be included based on user's preferences. @@ -1502,15 +1524,29 @@ class WatchlistScanner: unique_title_variations = list(dict.fromkeys(title_variations)) # Search for each artist with each title variation - + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + allow_duplicates = config_manager.get('wishlist.allow_duplicate_tracks', True) + for artist_name in artists_to_search: for query_title in unique_title_variations: - # Use same database check as modals with server awareness - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=album_name) - + # When allow_duplicates is on, skip album hint so we get title+artist matches only + search_album = None if allow_duplicates else album_name + db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=search_album) + if db_track and confidence >= 0.7: + # When allow_duplicates is on, only skip if the exact same album + if allow_duplicates and album_name: + lib_album = getattr(db_track, 'album_title', '') or '' + if lib_album: + from difflib import SequenceMatcher + album_sim = SequenceMatcher(None, album_name.lower(), lib_album.lower()).ratio() + if album_sim < 0.85: + logger.debug(f"Track found but different album (allow_duplicates=True): '{original_title}' — library: '{lib_album}', wanted: '{album_name}'") + continue # Different album — allow it + else: + # No album info in library — can't compare, allow it + continue logger.debug(f"Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})") return False # Track exists in library @@ -2128,6 +2164,11 @@ class WatchlistScanner: logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)") + # Skip albums with placeholder tracks (unreleased tracklist) + if self._has_placeholder_tracks(tracks): + logger.info(f" Skipping album with placeholder tracks: {album_data.get('name', 'Unknown')}") + continue + # Determine if this is a new release (within last 30 days) is_new = False try: diff --git a/core/web_scan_manager.py b/core/web_scan_manager.py index 343d36b4..d2110ae0 100644 --- a/core/web_scan_manager.py +++ b/core/web_scan_manager.py @@ -38,6 +38,8 @@ class WebScanManager: self._max_scan_time = 1800 # 30 minutes maximum self._current_server_type = None self._scan_progress = {} + self._completion_check_timer = None + self._shutting_down = False logger.info(f"WebScanManager initialized with {delay_seconds}s debounce delay") @@ -84,6 +86,14 @@ class WebScanManager: logger.info(f"Web scan requested - reason: {reason}") with self._lock: + if self._shutting_down: + logger.debug("Web scan request ignored during shutdown") + return { + "status": "ignored", + "message": "Server is shutting down", + "delay_seconds": 0, + "reason": reason, + } # Add callback if provided if callback and callback not in self._scan_completion_callbacks: self._scan_completion_callbacks.append(callback) @@ -107,6 +117,7 @@ class WebScanManager: # Start the debounce timer self._timer = threading.Timer(self.delay, self._execute_scan) + self._timer.daemon = True self._timer.start() return { @@ -169,6 +180,9 @@ class WebScanManager: def _execute_scan(self): """Execute the actual media library scan""" with self._lock: + if self._shutting_down: + logger.debug("Web scan execution skipped during shutdown") + return if self._scan_in_progress: logger.warning("Web scan already in progress - skipping duplicate execution") return @@ -231,6 +245,9 @@ class WebScanManager: """Start periodic checking for scan completion""" def check_completion(): try: + if self._shutting_down: + logger.debug("Web scan completion check aborted during shutdown") + return # Check for timeout if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time: logger.warning(f"Web scan timed out after {self._max_scan_time} seconds") @@ -254,23 +271,38 @@ class WebScanManager: self._handle_scan_completion() else: # Continue checking - threading.Timer(30, check_completion).start() # Check every 30 seconds + if self._shutting_down: + return + timer = threading.Timer(30, check_completion) # Check every 30 seconds + timer.daemon = True + self._completion_check_timer = timer + timer.start() except Exception as e: logger.error(f"Error during web scan completion check: {e}") self._reset_scan_state() # Start first check after 30 seconds - threading.Timer(30, check_completion).start() + timer = threading.Timer(30, check_completion) + timer.daemon = True + self._completion_check_timer = timer + timer.start() def _handle_scan_completion(self): """Handle scan completion and trigger callbacks""" - logger.info(f"Web {self._current_server_type.upper()} library scan completed") - - # Call completion callbacks - callbacks_to_call = [] with self._lock: + if self._shutting_down: + return + server_type = self._current_server_type callbacks_to_call = self._scan_completion_callbacks.copy() + downloads_during_scan = self._downloads_during_scan + + if not server_type: + logger.debug("Skipping web scan completion: no active server type") + self._reset_scan_state() + return + + logger.info(f"Web {server_type.upper()} library scan completed") for callback in callbacks_to_call: try: @@ -283,10 +315,9 @@ class WebScanManager: self._reset_scan_state() # Check if we need another scan due to downloads during this scan - with self._lock: - if self._downloads_during_scan: - logger.info("Web scan follow-up needed for downloads during scan") - self.request_scan("Follow-up scan for downloads during previous scan") + if downloads_during_scan: + logger.info("Web scan follow-up needed for downloads during scan") + self.request_scan("Follow-up scan for downloads during previous scan") def _reset_scan_state(self): """Reset internal scan state""" @@ -295,4 +326,24 @@ class WebScanManager: self._current_server_type = None self._scan_start_time = None self._scan_progress = {} - # Don't clear callbacks - they might be reused \ No newline at end of file + # Don't clear callbacks - they might be reused + + def shutdown(self): + """Cancel any pending timers and stop scheduling new work.""" + with self._lock: + self._shutting_down = True + self._scan_in_progress = False + self._current_server_type = None + self._scan_start_time = None + self._scan_progress = {} + + if self._timer: + self._timer.cancel() + self._timer = None + + if self._completion_check_timer: + self._completion_check_timer.cancel() + self._completion_check_timer = None + + self._downloads_during_scan = False + logger.info("WebScanManager shutdown - cancelled all pending timers") diff --git a/core/worker_utils.py b/core/worker_utils.py new file mode 100644 index 00000000..0c61fb0a --- /dev/null +++ b/core/worker_utils.py @@ -0,0 +1,17 @@ +"""Shared helpers for background workers.""" + +import threading + + +def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool: + """Sleep in chunks so shutdown can interrupt long waits.""" + if seconds <= 0: + return stop_event.is_set() + + remaining = float(seconds) + while remaining > 0 and not stop_event.is_set(): + wait_for = min(step, remaining) + if stop_event.wait(wait_for): + break + remaining -= wait_for + return stop_event.is_set() diff --git a/database/music_database.py b/database/music_database.py index 95b54646..eba95e57 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -16,6 +16,7 @@ from utils.logging_config import get_logger logger = get_logger("music_database") _database_initialized_paths = set() +_database_sidecar_warnings = set() _database_initialization_lock = threading.Lock() # Import matching engine for enhanced similarity logic @@ -170,10 +171,57 @@ class MusicDatabase: database_path = os.environ.get('DATABASE_PATH', 'database/music_library.db') self.database_path = Path(database_path) self.database_path.parent.mkdir(parents=True, exist_ok=True) + self._warn_about_stale_sqlite_sidecars() # Initialize database once per process for this path self._initialize_database_once() + def _warn_about_stale_sqlite_sidecars(self): + """Warn if SQLite sidecars are present and the database looks unhealthy.""" + db_key = str(self.database_path.resolve()) + with _database_initialization_lock: + if db_key in _database_sidecar_warnings: + return + _database_sidecar_warnings.add(db_key) + + wal_path = Path(f"{self.database_path}-wal") + shm_path = Path(f"{self.database_path}-shm") + existing = [p.name for p in (wal_path, shm_path) if p.exists()] + + if existing: + check_result = None + try: + conn = sqlite3.connect(f"file:{self.database_path}?mode=ro", uri=True, timeout=5.0) + try: + row = conn.execute("PRAGMA quick_check").fetchone() + check_result = row[0] if row else None + finally: + conn.close() + except Exception as e: + logger.warning( + "SQLite sidecar files detected for %s: %s, and database health check could not be run (%s). " + "This usually means the previous shutdown was not clean.", + self.database_path, + ", ".join(existing), + e, + ) + return + + if check_result != "ok": + logger.warning( + "SQLite sidecar files detected for %s: %s, and quick_check returned %r. " + "This usually means the previous shutdown was not clean.", + self.database_path, + ", ".join(existing), + check_result, + ) + else: + logger.debug( + "SQLite sidecar files present for %s (%s) but quick_check returned ok.", + self.database_path, + ", ".join(existing), + ) + def _initialize_database_once(self): """Run schema setup and migrations once per database path per process.""" db_key = str(self.database_path.resolve()) @@ -612,6 +660,23 @@ class MusicDatabase: except Exception: pass + # One-time migration: purge cached tracks/albums with junk artist names. + # The cache gate now rejects these, but existing entries need cleaning. + try: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_cache_junk_artist_purged'") + if not cursor.fetchone(): + cursor.execute("""DELETE FROM metadata_cache_entities + WHERE entity_type IN ('track', 'album') + AND (artist_name IS NULL + OR TRIM(artist_name) = '' + OR LOWER(TRIM(artist_name)) IN ('unknown', 'unknown artist', 'none', 'null'))""") + purged = cursor.rowcount + cursor.execute("CREATE TABLE _cache_junk_artist_purged (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)") + if purged > 0: + logger.info(f"Purged {purged} cached tracks/albums with junk artist names") + except Exception: + pass + conn.commit() logger.info("Database initialized successfully") @@ -977,12 +1042,15 @@ class MusicDatabase: cursor.execute("ALTER TABLE discovery_pool ADD COLUMN artist_genres TEXT") logger.info("Added artist_genres column to discovery_pool table") + if 'source' not in discovery_pool_columns: + cursor.execute("ALTER TABLE discovery_pool ADD COLUMN source TEXT DEFAULT 'spotify'") + logger.info("Added source column to discovery_pool table") + # Migration: Add iTunes columns to discovery_pool for dual-source discovery if 'itunes_track_id' not in discovery_pool_columns: cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_track_id TEXT") cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_album_id TEXT") cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_artist_id TEXT") - cursor.execute("ALTER TABLE discovery_pool ADD COLUMN source TEXT DEFAULT 'spotify'") logger.info("Added iTunes columns to discovery_pool table for dual-source discovery") # Migration: Add Deezer columns to discovery_pool for tri-source discovery @@ -1008,9 +1076,12 @@ class MusicDatabase: cursor.execute("PRAGMA table_info(recent_releases)") recent_releases_columns = [column[1] for column in cursor.fetchall()] + if 'source' not in recent_releases_columns: + cursor.execute("ALTER TABLE recent_releases ADD COLUMN source TEXT DEFAULT 'spotify'") + logger.info("Added source column to recent_releases table") + if 'album_itunes_id' not in recent_releases_columns: cursor.execute("ALTER TABLE recent_releases ADD COLUMN album_itunes_id TEXT") - cursor.execute("ALTER TABLE recent_releases ADD COLUMN source TEXT DEFAULT 'spotify'") logger.info("Added iTunes columns to recent_releases table for dual-source discovery") # Migration: Add Deezer column to recent_releases for tri-source discovery @@ -1022,10 +1093,13 @@ class MusicDatabase: cursor.execute("PRAGMA table_info(discovery_recent_albums)") discovery_recent_albums_columns = [column[1] for column in cursor.fetchall()] + if 'source' not in discovery_recent_albums_columns: + cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'") + logger.info("Added source column to discovery_recent_albums table") + if 'album_itunes_id' not in discovery_recent_albums_columns: cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN album_itunes_id TEXT") cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN artist_itunes_id TEXT") - cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'") logger.info("Added iTunes columns to discovery_recent_albums table for dual-source discovery") # Migration: Add Deezer columns to discovery_recent_albums for tri-source discovery @@ -1284,7 +1358,7 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)") - logger.info("Discovery tables created successfully") + logger.info("Discovery tables added/verified successfully") except Exception as e: logger.error(f"Error creating discovery tables: {e}") @@ -1450,6 +1524,8 @@ class MusicDatabase: include_remixes INTEGER DEFAULT 0, include_acoustic INTEGER DEFAULT 0, include_compilations INTEGER DEFAULT 0, + include_instrumentals INTEGER DEFAULT 0, + lookback_days INTEGER DEFAULT NULL, itunes_artist_id TEXT, deezer_artist_id TEXT, discogs_artist_id TEXT, @@ -1476,6 +1552,8 @@ class MusicDatabase: include_remixes INTEGER DEFAULT 0, include_acoustic INTEGER DEFAULT 0, include_compilations INTEGER DEFAULT 0, + include_instrumentals INTEGER DEFAULT 0, + lookback_days INTEGER DEFAULT NULL, itunes_artist_id TEXT, deezer_artist_id TEXT, discogs_artist_id TEXT @@ -1489,6 +1567,7 @@ class MusicDatabase: 'last_scan_timestamp', 'created_at', 'updated_at', 'image_url', 'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations', + 'include_instrumentals', 'lookback_days', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id'] shared_cols = [c for c in new_cols if c in old_cols] cols_str = ', '.join(shared_cols) @@ -1749,7 +1828,7 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_discogs_id ON albums (discogs_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_discogs_status ON albums (discogs_match_status)") - logger.info("Discogs enrichment columns added/verified") + logger.info("Discogs enrichment columns added/verified successfully") except Exception as e: logger.error(f"Error adding Discogs columns: {e}") @@ -2217,6 +2296,8 @@ class MusicDatabase: include_remixes INTEGER DEFAULT 0, include_acoustic INTEGER DEFAULT 0, include_compilations INTEGER DEFAULT 0, + include_instrumentals INTEGER DEFAULT 0, + lookback_days INTEGER DEFAULT NULL, itunes_artist_id TEXT, deezer_artist_id TEXT, discogs_artist_id TEXT, @@ -2231,6 +2312,7 @@ class MusicDatabase: 'last_scan_timestamp', 'created_at', 'updated_at', 'image_url', 'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations', + 'include_instrumentals', 'lookback_days', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id'] shared_cols = [c for c in new_cols if c in col_names] cols_str = ', '.join(shared_cols) @@ -2393,6 +2475,9 @@ class MusicDatabase: itunes_track_id TEXT, itunes_album_id TEXT, itunes_artist_id TEXT, + deezer_track_id TEXT, + deezer_album_id TEXT, + deezer_artist_id TEXT, source TEXT NOT NULL DEFAULT 'spotify', track_name TEXT NOT NULL, artist_name TEXT NOT NULL, @@ -2412,6 +2497,7 @@ class MusicDatabase: new_cols = ['id', 'spotify_track_id', 'spotify_album_id', 'spotify_artist_id', 'itunes_track_id', 'itunes_album_id', 'itunes_artist_id', + 'deezer_track_id', 'deezer_album_id', 'deezer_artist_id', 'source', 'track_name', 'artist_name', 'album_name', 'album_cover_url', 'duration_ms', 'popularity', 'release_date', 'is_new_release', 'track_data_json', 'artist_genres', 'added_date', 'profile_id'] @@ -2486,6 +2572,7 @@ class MusicDatabase: watchlist_artist_id INTEGER NOT NULL, album_spotify_id TEXT, album_itunes_id TEXT, + album_deezer_id TEXT, source TEXT NOT NULL DEFAULT 'spotify', album_name TEXT NOT NULL, release_date TEXT NOT NULL, @@ -2498,8 +2585,8 @@ class MusicDatabase: """) new_cols = ['id', 'watchlist_artist_id', 'album_spotify_id', 'album_itunes_id', - 'source', 'album_name', 'release_date', 'album_cover_url', - 'track_count', 'added_date', 'profile_id'] + 'album_deezer_id', 'source', 'album_name', 'release_date', + 'album_cover_url', 'track_count', 'added_date', 'profile_id'] shared_cols = [c for c in new_cols if c in old_cols] cols_str = ', '.join(shared_cols) @@ -2557,10 +2644,15 @@ class MusicDatabase: source_artist_id TEXT NOT NULL, similar_artist_spotify_id TEXT, similar_artist_itunes_id TEXT, + similar_artist_deezer_id TEXT, similar_artist_name TEXT NOT NULL, similarity_rank INTEGER DEFAULT 1, occurrence_count INTEGER DEFAULT 1, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + image_url TEXT, + genres TEXT, + popularity INTEGER DEFAULT 0, + metadata_updated_at TIMESTAMP, last_featured TIMESTAMP, profile_id INTEGER DEFAULT 1, UNIQUE(profile_id, source_artist_id, similar_artist_name) @@ -2568,9 +2660,10 @@ class MusicDatabase: """) new_cols = ['id', 'source_artist_id', 'similar_artist_spotify_id', - 'similar_artist_itunes_id', 'similar_artist_name', - 'similarity_rank', 'occurrence_count', 'last_updated', - 'last_featured', 'profile_id'] + 'similar_artist_itunes_id', 'similar_artist_deezer_id', + 'similar_artist_name', 'similarity_rank', 'occurrence_count', + 'last_updated', 'image_url', 'genres', 'popularity', + 'metadata_updated_at', 'last_featured', 'profile_id'] shared_cols = [c for c in new_cols if c in old_cols] cols_str = ', '.join(shared_cols) @@ -4812,25 +4905,27 @@ class MusicDatabase: return [] def search_artists(self, query: str, limit: int = 50, server_source: str = None) -> List[DatabaseArtist]: - """Search artists by name, optionally filtered by server source.""" + """Search artists by name, optionally filtered by server source. + Uses diacritic-insensitive matching so 'Tiesto' finds 'Tiësto'.""" try: conn = self._get_connection() cursor = conn.cursor() + norm_query = f"%{self._normalize_for_comparison(query)}%" if server_source: cursor.execute(""" SELECT * FROM artists - WHERE name LIKE ? AND server_source = ? + WHERE unidecode_lower(name) LIKE ? AND server_source = ? ORDER BY name LIMIT ? - """, (f"%{query}%", server_source, limit)) + """, (norm_query, server_source, limit)) else: cursor.execute(""" SELECT * FROM artists - WHERE name LIKE ? + WHERE unidecode_lower(name) LIKE ? ORDER BY name LIMIT ? - """, (f"%{query}%", limit)) + """, (norm_query, limit)) rows = cursor.fetchall() @@ -6391,12 +6486,33 @@ class MusicDatabase: spotify_json = json.dumps(spotify_track_data) source_json = json.dumps(source_info or {}) - # No duplicate found, insert the track + # When allow_duplicates is on, make the key unique per album so the same + # track from different albums can coexist in the wishlist + insert_track_id = track_id + if allow_duplicates: + album_obj = spotify_track_data.get('album', {}) + album_id = album_obj.get('id', '') if isinstance(album_obj, dict) else '' + if album_id: + # Check if this exact track+album combo already exists + composite_id = f"{track_id}::{album_id}" + cursor.execute("SELECT id FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?", + (composite_id, profile_id)) + if cursor.fetchone(): + logger.debug(f"Skipping wishlist entry — same track+album already in wishlist: '{track_name}' on '{album_obj.get('name', '')}'") + return False + # Check if base track_id exists (from a different album) + cursor.execute("SELECT id FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?", + (track_id, profile_id)) + if cursor.fetchone(): + # Same track exists from different album — use composite ID + insert_track_id = composite_id + + # Insert the track cursor.execute(""" INSERT OR REPLACE INTO wishlist_tracks (spotify_track_id, spotify_data, failure_reason, source_type, source_info, date_added, profile_id) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) - """, (track_id, spotify_json, failure_reason, source_type, source_json, profile_id)) + """, (insert_track_id, spotify_json, failure_reason, source_type, source_json, profile_id)) conn.commit() diff --git a/database/music_library.db b/database/music_library.db deleted file mode 100644 index fc442bc7..00000000 Binary files a/database/music_library.db and /dev/null differ diff --git a/web_server.py b/web_server.py index 2dce6a25..5af7d48b 100644 --- a/web_server.py +++ b/web_server.py @@ -21,18 +21,20 @@ from urllib.parse import urljoin from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g from flask_socketio import SocketIO, emit, join_room, leave_room -from utils.logging_config import get_logger +from utils.logging_config import get_logger, setup_logging from utils.async_helpers import run_async # --- Core Application Imports --- # Import the same core clients and config manager used by the GUI app from config.settings import config_manager -# Initialize logger -logger = get_logger("web_server") +# Setup logging early to avoid any import-time logs from being swallowed +_log_level = config_manager.get('logging.level', 'INFO') +_log_path = config_manager.get('logging.path', 'logs/app.log') +logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -SOULSYNC_VERSION = "2.2" +SOULSYNC_VERSION = "2.3" # Dedicated source reuse logger — writes to logs/source_reuse.log import logging as _logging @@ -2517,27 +2519,41 @@ class WebUIDownloadMonitor: self.monitoring = False self.monitor_thread = None self.monitored_batches = set() + self._lock = threading.Lock() def start_monitoring(self, batch_id): """Start monitoring a download batch""" - self.monitored_batches.add(batch_id) - if not self.monitoring: - self.monitoring = True - self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) - self.monitor_thread.start() - print(f"Started download monitor for batch {batch_id}") + with self._lock: + self.monitored_batches.add(batch_id) + if not self.monitoring: + self.monitoring = True + self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) + self.monitor_thread.start() + print(f"Started download monitor for batch {batch_id}") def stop_monitoring(self, batch_id): """Stop monitoring a specific batch""" - self.monitored_batches.discard(batch_id) - if not self.monitored_batches: + with self._lock: + self.monitored_batches.discard(batch_id) + if not self.monitored_batches: + self.monitoring = False + print(f"Stopped download monitor (no active batches)") + + def shutdown(self): + """Stop the monitor loop and clear active batch tracking.""" + with self._lock: self.monitoring = False - print(f"Stopped download monitor (no active batches)") + self.monitored_batches.clear() + self.monitor_thread = None + print("Download monitor shutdown requested") def _monitor_loop(self): """Main monitoring loop - checks downloads every 1 second for responsive web UX""" while self.monitoring and self.monitored_batches: try: + if globals().get('IS_SHUTTING_DOWN', False): + self.monitoring = False + break self._check_all_downloads() time.sleep(1) # 1-second polling for fast web UI updates except Exception as e: @@ -2624,6 +2640,8 @@ class WebUIDownloadMonitor: completed_tasks.append((batch_id, task_id)) # ---- All work below runs WITHOUT tasks_lock held ---- + if globals().get('IS_SHUTTING_DOWN', False) or not self.monitoring: + return # Execute deferred operations from _should_retry_task (network calls, nested locks) for op in deferred_ops: @@ -3206,6 +3224,8 @@ def validate_and_heal_batch_states(): This is the server-side equivalent of the frontend's worker count validation. """ try: + if globals().get('IS_SHUTTING_DOWN', False): + return import time current_time = time.time() @@ -3310,15 +3330,41 @@ def validate_and_heal_batch_states(): # Start periodic batch healing (every 30 seconds) import threading +_batch_healing_timer = None +_batch_healing_timer_lock = threading.Lock() + +def _schedule_batch_healing_timer(delay_seconds=30.0): + """Schedule the next batch healing cycle.""" + global _batch_healing_timer + if globals().get('IS_SHUTTING_DOWN', False): + return + + timer = threading.Timer(delay_seconds, start_batch_healing_timer) + timer.daemon = True + with _batch_healing_timer_lock: + _batch_healing_timer = timer + timer.start() + +def _cancel_batch_healing_timer(): + """Cancel the current batch healing timer if one exists.""" + global _batch_healing_timer + with _batch_healing_timer_lock: + timer = _batch_healing_timer + _batch_healing_timer = None + if timer: + timer.cancel() + def start_batch_healing_timer(): """Start periodic batch state validation and healing""" try: + if globals().get('IS_SHUTTING_DOWN', False): + return validate_and_heal_batch_states() except Exception as e: print(f"[Batch Healing Timer] Error: {e}") finally: # Schedule next healing cycle - threading.Timer(30.0, start_batch_healing_timer).start() + _schedule_batch_healing_timer(30.0) # Start the healing timer when the server starts start_batch_healing_timer() @@ -3332,35 +3378,85 @@ def cleanup_monitor(): """Clean up background monitor on shutdown""" if download_monitor.monitoring: print("Flask shutdown detected, stopping download monitor...") - download_monitor.monitoring = False - download_monitor.monitored_batches.clear() + download_monitor.shutdown() # Give the thread a moment to exit cleanly time.sleep(0.5) # Clean up batch locks to prevent memory leaks - with tasks_lock: - batch_locks.clear() - print("Cleaned up batch locks") + try: + acquired = tasks_lock.acquire(timeout=1.0) + if acquired: + try: + batch_locks.clear() + print("Cleaned up batch locks") + finally: + tasks_lock.release() + else: + print("Skipped batch lock cleanup - tasks_lock busy") + except Exception as e: + print(f"Error cleaning up batch locks: {e}") # Global shutdown flag IS_SHUTTING_DOWN = False -def signal_handler(signum, frame): - """Handle SIGINT (Ctrl+C) and SIGTERM""" - global IS_SHUTTING_DOWN - print(f"Signal {signum} received, cleaning up...") - IS_SHUTTING_DOWN = True - cleanup_monitor() - - # Stop automation engine +def _shutdown_executor(executor, name): + """Shut down a ThreadPoolExecutor without waiting for long-running tasks.""" + if executor is None: + return try: - if automation_engine: - print("Stopping automation engine...") - automation_engine.stop() + print(f"Shutting down {name}...") + executor.shutdown(wait=False, cancel_futures=True) except Exception as e: - print(f"Error stopping automation engine: {e}") + print(f"Error shutting down {name}: {e}") - # Persist API call history +def _stop_component(component, name, method_names=("stop", "shutdown")): + """Call a best-effort stop method on a component if it has one.""" + if component is None: + return + for method_name in method_names: + method = getattr(component, method_name, None) + if callable(method): + try: + print(f"Stopping {name}...") + method() + except Exception as e: + print(f"Error stopping {name}: {e}") + return + +def _stop_components_parallel(components): + """Stop multiple components concurrently and wait for all stop calls to finish.""" + stop_threads = [] + + for component, name in components: + if component is None: + continue + + thread = threading.Thread( + target=_stop_component, + args=(component, name), + name=f"shutdown-{name.replace(' ', '-')}", + ) + thread.start() + stop_threads.append((name, thread)) + + for name, thread in stop_threads: + thread.join() + +def _shutdown_runtime_components(): + """Best-effort shutdown for timers, monitors, workers, and executors.""" + global IS_SHUTTING_DOWN + if IS_SHUTTING_DOWN: + return + + IS_SHUTTING_DOWN = True + _cancel_batch_healing_timer() + + cleanup_monitor() + + _stop_component(web_scan_manager, "web scan manager") + _stop_component(automation_engine, "automation engine") + + # Persist API call history before shutting down worker pools. try: from core.api_call_tracker import api_call_tracker api_call_tracker.save() @@ -3368,13 +3464,57 @@ def signal_handler(signum, frame): except Exception as e: print(f"Error saving API call history: {e}") - # Shutdown executor to prevent new tasks - try: - print("Shutting down missing_download_executor...") - missing_download_executor.shutdown(wait=False, cancel_futures=True) - except Exception as e: - print(f"Error shutting down executor: {e}") + # Stop the active DB update worker before tearing down the executor it runs on. + # This lets an in-flight update observe should_stop and exit cleanly. + _stop_component(db_update_worker, "db update worker") + _stop_component(metadata_update_runtime_worker, "metadata update worker") + # Stop long-lived worker components in parallel so shutdown waits for the + # slowest worker instead of serially burning the timeout for each one. + _stop_components_parallel([ + (mb_worker, "musicbrainz worker"), + (audiodb_worker, "audiodb worker"), + (discogs_worker, "discogs worker"), + (deezer_worker, "deezer worker"), + (spotify_enrichment_worker, "spotify enrichment worker"), + (itunes_enrichment_worker, "itunes enrichment worker"), + (lastfm_worker, "lastfm worker"), + (genius_worker, "genius worker"), + (tidal_enrichment_worker, "tidal enrichment worker"), + (qobuz_enrichment_worker, "qobuz enrichment worker"), + (hydrabase_worker, "hydrabase worker"), + (soulid_worker, "soulid worker"), + (listening_stats_worker, "listening stats worker"), + (repair_worker, "repair worker"), + ]) + + # Shut down executor pools so their worker threads stop keeping the process alive. + for executor, name in [ + (stream_executor, "stream executor"), + (db_update_executor, "db update executor"), + (quality_scanner_executor, "quality scanner executor"), + (duplicate_cleaner_executor, "duplicate cleaner executor"), + (retag_executor, "retag executor"), + (sync_executor, "sync executor"), + (missing_download_executor, "missing download executor"), + (tidal_discovery_executor, "tidal discovery executor"), + (deezer_discovery_executor, "deezer discovery executor"), + (spotify_public_discovery_executor, "spotify public discovery executor"), + (youtube_discovery_executor, "youtube discovery executor"), + (beatport_discovery_executor, "beatport discovery executor"), + (listenbrainz_discovery_executor, "listenbrainz discovery executor"), + (similar_artists_executor, "similar artists executor"), + (metadata_update_executor, "metadata update executor"), + ]: + _shutdown_executor(executor, name) + + # Give daemon cleanup threads a moment to observe the shutdown flag. + time.sleep(0.2) + +def signal_handler(signum, frame): + """Handle SIGINT (Ctrl+C) and SIGTERM""" + print(f"Signal {signum} received, cleaning up...") + _shutdown_runtime_components() sys.exit(0) # Register cleanup handlers @@ -3385,7 +3525,14 @@ def _atexit_save_history(): except Exception: pass +def _atexit_shutdown(): + try: + _shutdown_runtime_components() + except Exception: + pass + atexit.register(_atexit_save_history) +atexit.register(_atexit_shutdown) atexit.register(cleanup_monitor) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) @@ -18444,7 +18591,7 @@ def _embed_source_ids(audio_file, metadata: dict): _pp_album_name = metadata.get('album', '') _pp_artist_name = metadata.get('album_artist', '') or metadata.get('artist', '') if _pp_album_name and _pp_artist_name: - conn = database._get_connection() + conn = get_database()._get_connection() try: cursor = conn.cursor() cursor.execute(""" @@ -20133,6 +20280,61 @@ def _post_process_matched_download(context_key, context, file_path): print(f"Post-processing failed: Missing spotify_artist context.") return + # ── UNKNOWN ARTIST GUARD ── + # If artist name is junk, attempt to resolve from track metadata before proceeding. + # This prevents files from landing in "Unknown Artist/" folders. + _junk_artist_names = {'', 'unknown', 'unknown artist', 'various artists', 'none', 'null'} + _artist_name = (spotify_artist.get('name', '') if isinstance(spotify_artist, dict) else '').strip() + if _artist_name.lower() in _junk_artist_names: + print(f"[Unknown Artist Guard] Artist name is '{_artist_name}' — attempting to resolve") + _resolved = False + track_info_guard = context.get("track_info", {}) or {} + original_search_guard = context.get("original_search_result", {}) or {} + + # Try 1: Pull artist from track_info.artists + _ti_artists = track_info_guard.get('artists', []) + if isinstance(_ti_artists, list) and _ti_artists: + _first = _ti_artists[0] + _name = _first.get('name', '') if isinstance(_first, dict) else str(_first) + if _name and _name.strip().lower() not in _junk_artist_names: + spotify_artist['name'] = _name.strip() + print(f"[Unknown Artist Guard] Resolved from track_info.artists: '{_name}'") + _resolved = True + + # Try 2: Pull from original_search_result + if not _resolved: + _os_artist = original_search_guard.get('artist') or original_search_guard.get('artist_name') or '' + if isinstance(_os_artist, str) and _os_artist.strip().lower() not in _junk_artist_names: + spotify_artist['name'] = _os_artist.strip() + print(f"[Unknown Artist Guard] Resolved from original_search_result: '{_os_artist}'") + _resolved = True + + # Try 3: Re-fetch from metadata source using track ID + if not _resolved: + _track_id = track_info_guard.get('id') or track_info_guard.get('track_id') or '' + if _track_id: + try: + _fb_client = _get_metadata_fallback_client() + if hasattr(_fb_client, 'get_track_details'): + _details = _fb_client.get_track_details(str(_track_id)) + if _details and isinstance(_details, dict): + _d_artists = _details.get('artists', []) + if isinstance(_d_artists, list) and _d_artists: + _d_first = _d_artists[0] + _d_name = _d_first.get('name', '') if isinstance(_d_first, dict) else str(_d_first) + if _d_name and _d_name.strip().lower() not in _junk_artist_names: + spotify_artist['name'] = _d_name.strip() + print(f"[Unknown Artist Guard] Resolved from metadata API: '{_d_name}'") + _resolved = True + except Exception as _guard_err: + print(f"[Unknown Artist Guard] Metadata re-fetch failed: {_guard_err}") + + if not _resolved: + print(f"[Unknown Artist Guard] Could not resolve artist — proceeding with '{_artist_name}'") + + context['spotify_artist'] = spotify_artist + # ── END UNKNOWN ARTIST GUARD ── + # Check if playlist folder mode is enabled (sync page playlists only) track_info = context.get("track_info", {}) playlist_folder_mode = track_info.get("_playlist_folder_mode", False) @@ -21332,180 +21534,120 @@ def get_version_info(): "sections": [ { "title": "Centralized Downloads Page", - "description": "Live view of every download across the entire app in one place", + "description": "Live view of every download across the entire app — tracks from Sync, Discover, Artists, Search, and Wishlist all in one place", "features": [ - "• New Downloads page in sidebar — shows all tracks from Sync, Discover, Artists, Search, and Wishlist", - "• Live-updating list with filter pills: All, Active, Queued, Completed, Failed", - "• Section headers group downloads by status with track counts", - "• Track position display (3 of 19) for album and playlist batches", - "• Album art, artist/album metadata, batch context, and error messages per row", + "• New Downloads page in sidebar with live-updating list and nav badge", + "• Filter pills: All, Active, Queued, Completed, Failed", + "• Track position (3 of 19), album art, batch context, and error details per row", "• Clear Completed button removes finished items from the tracker", - "• Nav badge shows active download count from any page via WebSocket", ], - "usage_note": "Click Downloads in the sidebar to see all active and recent downloads. The badge updates in real-time from any page." + "usage_note": "Click Downloads in the sidebar. The badge updates in real-time from any page." }, { "title": "First-Run Setup Wizard", - "description": "New full-screen guided setup for first-time users", + "description": "Full-screen guided setup that walks new users through configuration and their first download", "features": [ - "• 7-step wizard: Welcome, Metadata Source, Download Source, Paths & Media Server, Add Artists, First Download, Done", - "• All 6 download sources available: Soulseek, YouTube, HiFi, Tidal, Qobuz, Deezer — with inline config and test buttons", - "• Path fields default to /app/downloads and /app/Transfer with lock/unlock for Docker users", - "• Media server connection (Plex/Jellyfin/Navidrome) with inline test", - "• Add artists to watchlist with live search — shows watchlist status, add/remove in place", - "• First download step searches metadata, finds best match, and downloads through the full pipeline", - "• All settings save to DB identically to the Settings page — no difference in behavior", + "• 7-step wizard: Welcome, Metadata Source, Download Source, Paths, Media Server, Add Artists, First Download", + "• All 6 download sources with inline config and test buttons", + "• Locked path defaults for Docker users (/app/downloads, /app/Transfer)", + "• Add artists to watchlist with live search and remove in place", + "• First download goes through the full matched download pipeline with metadata", + "• Auto-shows on fresh installs, skippable, all settings save to DB", + "• Done page with tips grid covering Sync, Wishlist, Automations, Notifications, Help, and Settings", + ], + "usage_note": "Auto-shows for new users. Re-open anytime with ?setup=1 in the URL." + }, + { + "title": "Graceful Shutdown & Stability", + "description": "Application now shuts down cleanly within 1 second instead of 60+", + "features": [ + "• All background workers use interruptible sleep — respond to shutdown signals immediately", + "• Docker containers no longer force-kill, preventing SQLite WAL corruption", + "• Parallel component shutdown for scan managers, repair workers, executors", + "• Download monitor thread safety with proper locking", + "• Logging initialized early so import-time diagnostic messages are captured", + "• Database initialization fixed for fresh installs — all migrations run in a single cycle", + ], + "usage_note": "Docker users: containers now stop gracefully within the default 10-second timeout." + }, + { + "title": "Unknown Artist Prevention", + "description": "Multi-layer defense against tracks downloading as 'Unknown Artist'", + "features": [ + "• Metadata cache now rejects tracks with junk artist names — prevents caching incomplete data", + "• Post-processing 3-tier fallback: check track_info, search result, then re-fetch from metadata API", + "• Files never land in 'Unknown Artist' folders — guard runs before folder creation and tag embedding", + ], + }, + { + "title": "Deezer Multi-Artist Tagging", + "description": "Feature tracks now tag all credited artists using Deezer's contributors field", + "features": [ + "• ARTIST tag includes all contributors (e.g. 'Kraftklub, Domiziana') instead of just the primary", + "• Album artist tag unchanged — folder organization unaffected", + "• Falls back gracefully when contributors field is absent (search results)", ], - "usage_note": "Open with ?setup=1 URL parameter or openSetupWizard() from browser console. First-run auto-detection coming soon." }, { "title": "Music Videos — Search & Download from YouTube", - "description": "New Music Videos tab in enhanced and global search for finding and downloading music videos", + "description": "Music Videos tab in enhanced and global search", "features": [ - "• Music Videos pill tab alongside Spotify/Deezer/iTunes/Discogs in both search bars", - "• YouTube search returns video cards with 16:9 thumbnails, duration, channel name, view count", - "• Click any video to download — circular progress ring on thumbnail, green checkmark on completion", - "• Metadata matching — searches your primary source for clean artist/title before saving", - "• Saves to configurable Music Videos directory as Artist/Title-video.mp4 (Plex global folder format)", - "• Plex video type suffixes supported: -video, -lyrics, -live, -concert, -interview, -behindthescenes" + "• Video cards with thumbnails, duration, channel name, view count", + "• Click to download with progress ring — metadata matched before saving", + "• Saves to configurable Music Videos directory (Plex format)", ], - "usage_note": "Set your Music Videos directory in Settings > Downloads, then search for any artist in the search bar and click the Music Videos tab." + "usage_note": "Set your Music Videos directory in Settings > Downloads, then use the Music Videos tab in search." }, { "title": "Lidarr Download Source (Development)", - "description": "Use Lidarr as a download source for Usenet and torrent content", + "description": "7th download source for Usenet and torrent content via Lidarr", "features": [ - "• 7th download source alongside Soulseek, YouTube, Tidal, Qobuz, HiFi, and Deezer", - "• SoulSync handles discovery and matching, Lidarr handles downloading via its indexers", - "• Configure with just URL + API key in Settings > Downloads", + "• SoulSync handles discovery and matching, Lidarr handles downloading", + "• Configure with URL + API key in Settings > Downloads", "• Available as standalone source or in Hybrid mode priority order", - "• Currently in development — basic album search and download flow functional" ], - "usage_note": "Requires a running Lidarr instance with configured indexers and download clients. Set Download Source to 'Lidarr Only (Development)' or add to Hybrid order." + "usage_note": "Requires a running Lidarr instance. Set Download Source to 'Lidarr Only (Development)' or add to Hybrid order." }, { - "title": "Metadata Pipeline Overhaul — Fix Unknown Artist & Source Selection", - "description": "Major fix for tracks downloading as 'Unknown Artist' and Spotify being used when Deezer/iTunes was selected", + "title": "Matching & Quality Improvements", + "description": "Better album matching, per-track artist support, and placeholder detection", "features": [ - "• Fixed playlist pipeline (discover → sync → wishlist → download) losing artist, track number, and album year data", - "• All discovery workers now respect your configured primary metadata source instead of always using Spotify", - "• Centralized metadata source selection in core/metadata_service.py — one source of truth for all features", - "• Fixed Deezer metadata cache returning incomplete data (missing track_number, release_date) from search result cache", - "• Sync completion toast now shows which specific tracks failed to match (not just a count)", - "• New 'Fix Unknown Artists' maintenance job — scans library for Unknown Artist tracks and corrects metadata, tags, and file paths", - "• One-time migration purges stale discovery and Deezer cache entries on first startup after update" + "• Album matching uses full similarity instead of word subset — 'Paradise' no longer matches 'Club Paradise'", + "• Artist gate prevents wrong-artist downloads (Soulseek < 0.25, YouTube < 0.15 threshold)", + "• Word boundary matching for artists — 'muse' no longer matches 'museum'", + "• Watchlist scanner skips albums with placeholder tracks ('Track 1', 'Track 2') from unreleased tracklists", + "• Per-track artist column for compilations and DJ mixes — tag writer uses track artist, not album artist", + "• Centralized metadata source selection — all features respect your configured source", ], - "usage_note": "If you have existing Unknown Artist tracks, run the Fix Unknown Artists job from Settings > Maintenance." - }, - { - "title": "Matching Engine — Artist Verification Gate", - "description": "Prevents downloading tracks from completely wrong artists on Soulseek and YouTube", - "features": [ - "• New artist gate rejects candidates where the artist doesn't match the target (Soulseek: < 0.25, YouTube: < 0.15)", - "• Fixed artist substring matching — 'muse' no longer matches 'museum', 'art' no longer matches 'heart'", - "• Artist similarity now compared per path segment instead of full filename — misspelled artist names still match correctly", - "• YouTube artist weight increased from 10% to 20% to reduce wrong-uploader matches", - "• Seasonal discovery, personalized playlists, and playlist explorer all use configured source instead of Spotify" - ], - "usage_note": "No action needed — matching improvements apply automatically to all new downloads." - }, - { - "title": "Deezer User Playlists — Browse & Download Your Library", - "description": "New Deezer tab on the Sync page shows your personal playlists via ARL token — same flow as Spotify", - "features": [ - "• Click Refresh to load all your Deezer playlists with track counts", - "• Click any playlist to view tracks, then Download Missing or Sync — no discovery step needed", - "• Existing Deezer URL import moved to 'Deezer Link' tab (unchanged)", - "• ARL token field added to Connections tab alongside Downloads tab with bidirectional sync", - "• Album release dates fetched for proper $year template variable support" - ], - "usage_note": "Configure your ARL token in Settings > Connections or Downloads, then open the Deezer tab on the Sync page." - }, - { - "title": "Qobuz Token Auth — CAPTCHA Bypass", - "description": "Qobuz added reCAPTCHA to their login — token auth lets you paste your session token directly", - "features": [ - "• New 'Auth Token' field on both Connections and Downloads tabs for Qobuz", - "• Log into play.qobuz.com in your browser, copy X-User-Auth-Token from DevTools, paste it in", - "• Bypasses the CAPTCHA entirely — existing email/password login still works if your session is active", - "• Token is validated and saved as a normal session — identical to email/password login" - ], - "usage_note": "If Qobuz email/password login fails, use the Auth Token field instead." - }, - { - "title": "Streaming Source Matching — Artist Gate", - "description": "Tidal, Qobuz, HiFi, and Deezer downloads no longer match to wrong artists", - "features": [ - "• Artist similarity gate rejects candidates below 0.4 match threshold", - "• Streaming source threshold raised from 0.55 to 0.60", - "• No more fallback to lenient Soulseek filename matcher for structured API sources", - "• Fixed single-char artist containment bug (e.g. 'B小町' no longer matches 'B.B. King')", - "• YouTube and Soulseek matching completely unchanged" - ], - "usage_note": "Downloads from official sources are now much more accurate. Check Download History for verification details." - }, - { - "title": "Download History — Source Provenance", - "description": "Collapsible download history with full source tracking and AcoustID verification badges", - "features": [ - "• Expected vs Downloaded comparison — shows what you asked for vs what the source provided", - "• Mismatched downloads highlighted in red for easy identification", - "• AcoustID verification badge per entry: Verified (green), Failed (red), Skipped (orange), Off (gray)", - "• Source filename, track ID, and artist saved with every download", - "• Click anywhere on an entry to expand/collapse details" - ], - "usage_note": "Click 'Download History' on the Dashboard to see source provenance for new downloads." }, { "title": "Fixes & Improvements", - "description": "Bug fixes, quality of life improvements, and new settings", + "description": "60+ commits of bug fixes, UX improvements, and infrastructure", "features": [ - "• Dismissed maintenance findings no longer reappear on next scan — dedup check now includes dismissed status", - "• Orphan file detector: increased path matching depth to 4 segments + filename parsing fallback for unreadable tags", - "• Media player: rapid play clicks no longer create duplicate audio streams requiring browser refresh", - "• Logs directory auto-created on startup — prevents crash for non-Docker installations", - "• Stale discovery data re-processed in automation pipeline — tracks with missing metadata get re-enriched", - "• Artist names no longer stored as lowercase — fixed static method shadowing instance method. Run a database update to fix existing names.", - "• Watchlist scanner skips future/unreleased albums — no more garbage downloads from albums not yet out", - "• Playlist sync tracks now tagged with correct track numbers instead of always 01", - "• Emby playlist sync fixed — integer IDs now accepted alongside Jellyfin GUIDs", - "• Discovery fix search now tries all metadata sources (Spotify → Deezer → iTunes) with automatic fallback", - "• Album completeness scanner skips zero-track albums to prevent auto-fill errors", - "• Global search string escaping fixed — albums with newlines in metadata no longer crash", - "• Download history timestamps fixed — no longer always showing 'Just now'", - "• Discogs added to enrichment service whitelist — Enrich button now works for Discogs", - "• Settings Connections tab redesigned with collapsible accordion services and brand-colored dots", - "• Metadata source filter on Library page — filter artists by matched/unmatched to any service", - "• Database Maintenance UI — VACUUM and incremental vacuum in Settings > Advanced", - "• Music Library Paths setting — configure where your music files live for tag writing and file detection", - "• Replace lower quality files on import — opt-in toggle in Settings > Library", - "• HiFi API instance health check in Settings > Downloads", - "• Debug test activity feed message removed from startup", - "• Global search downloads now create bubble snapshots on Dashboard and Search page", - "• Dead file findings now offer 'Remove from DB' option alongside 'Re-download' — works in bulk fix too", - "• Deezer ARL sync and download modals rehydrate after page refresh", - "• Deezer album data (release dates, cover art) cached in metadata cache — subsequent playlist loads are near-instant" - ] - }, - { - "title": "Artist Map — Visualize Your Music Universe", - "description": "Three interactive canvas-based visualization modes on the Discover page", - "features": [ - "• Watchlist Constellation — your watched artists as large nodes with similar artists orbiting around them", - "• Genre Map — browse all artists by genre with a sidebar picker, ring-packed clusters, no artist cap", - "• Artist Explorer — deep-dive any artist, ring 1 (direct similar) + ring 2 (extended network)", - "• On-the-fly discovery — exploring an unknown artist fetches similar artists from MusicMap in real-time and caches them", - "• Invalid artist names validated against Spotify/iTunes before loading the map", - "• Offscreen canvas buffer rendering with LOD — handles 1000+ nodes smoothly", - "• Image proxy endpoint solves CORS for canvas — Deezer, Last.fm, Discogs images now render on bubbles", - "• Direct CORS fetch first (zero server load), proxy only as fallback for non-CORS CDNs", - "• Server-side 5-minute cache on all map endpoints — switching genres and reopening is instant", - "• Cache auto-invalidates on watchlist changes, scans, and new similar artist discoveries", - "• Keyboard shortcuts (?, F for fit, S for search), mouse wheel zoom, click-to-explore", - "• Hover constellation effect with fade animation, rich tooltips with genre tags" + "• Plex playlists crash on Tag objects fixed — safe attribute access for non-playlist items", + "• Music library paths now auto-save when added/removed on settings page", + "• M3U files no longer created for single track downloads — only playlists", + "• M3U [object Object] artist bug fixed — handles all artist format variations", + "• Album year update 'database not defined' error fixed in post-processing", + "• Sync tab content scrolling fixed — long lists no longer clipped", + "• Hybrid download status shows green when any serverless source is in the order", + "• Serverless sources (YouTube, HiFi, Qobuz) always show green in service status", + "• Repeated slskd 401 errors suppressed after first warning", + "• Download clients reload paths when settings change (no restart needed)", + "• watchlist_artists table migrations include all provider ID columns in rebuilds", + "• Emojis removed from all Python log and print statements", + "• Interactive help coverage expanded with 30+ new entries", + "• Docker compose includes optional slskd service block", + "• Multi-stage Docker build reduces image size", + "• MusicBrainz recording ID backfilled from Navidrome during scan", ], - "usage_note": "Navigate to Discover and click the Artist Map section. Choose Watchlist, Genre, or Explorer mode." }, + # v2.2 and earlier features moved to archive + ] + } + return jsonify(version_data) + +_OLD_V22_NOTES = """ { "title": "Wing It — Download or Sync Without Discovery", "description": "Bypass metadata discovery and use raw track names directly", @@ -22159,9 +22301,7 @@ def get_version_info(): "• Wishlist process API endpoint for external apps" ] } - ] - } - return jsonify(version_data) +""" # end of _OLD_V22_NOTES _OLD_V2_NOTES = r""" "features": [ @@ -22726,7 +22866,7 @@ def _simple_monitor_task(): Search cleanup and download cleanup are now handled by system automations.""" print("Simple background monitor started") - while True: + while not globals().get('IS_SHUTTING_DOWN', False): try: with matched_context_lock: pending_count = len(matched_downloads_context) @@ -22753,6 +22893,8 @@ def _simple_monitor_task(): print(f"Simple monitor error: {e}") time.sleep(10) + print("Simple background monitor stopped") + def start_simple_background_monitor(): """Starts the simple background monitor thread.""" monitor_thread = threading.Thread(target=_simple_monitor_task) @@ -30419,7 +30561,7 @@ def get_server_playlists(): raw_playlists = plex_client.server.playlists() logger.info(f"[ServerPlaylists] Plex returned {len(raw_playlists)} total playlists") for playlist in raw_playlists: - if playlist.playlistType == 'audio': + if getattr(playlist, 'playlistType', None) == 'audio': playlists_data.append({ 'id': str(playlist.ratingKey), 'name': playlist.title, @@ -40717,6 +40859,7 @@ metadata_update_state = { } metadata_update_worker = None +metadata_update_runtime_worker = None metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="metadata_update") # =============================== @@ -44831,7 +44974,7 @@ def _old_get_listenbrainz_playlist_tracks_DEPRECATED(playlist_mbid): @app.route('/api/metadata/start', methods=['POST']) def start_metadata_update(): """Start the metadata update process - EXACT copy of dashboard.py logic""" - global metadata_update_worker, metadata_update_state + global metadata_update_worker, metadata_update_runtime_worker, metadata_update_state try: # Check if already running @@ -44899,6 +45042,7 @@ def start_metadata_update(): # Start the metadata update worker - EXACTLY like dashboard.py def run_metadata_update(): + global metadata_update_runtime_worker try: metadata_worker = WebMetadataUpdateWorker( None, # Artists will be loaded in the worker thread - EXACTLY like dashboard.py @@ -44907,12 +45051,15 @@ def start_metadata_update(): active_server, refresh_interval_days ) + metadata_update_runtime_worker = metadata_worker metadata_worker.run() except Exception as e: print(f"Error in metadata update worker: {e}") metadata_update_state['status'] = 'error' metadata_update_state['error'] = str(e) add_activity_item("", "Metadata Error", str(e), "Now") + finally: + metadata_update_runtime_worker = None metadata_update_worker = metadata_update_executor.submit(run_metadata_update) @@ -52046,7 +52193,7 @@ def _hydrabase_reconnect_loop(): global _hydrabase_ws _consecutive_failures = 0 - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(30) try: # Only attempt reconnect if auto_connect is enabled @@ -52096,7 +52243,7 @@ def _hydrabase_reconnect_loop(): def _emit_service_status_loop(): """Background thread that pushes service status every 5 seconds.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(5) try: socketio.emit('status:update', _build_status_payload()) @@ -52105,7 +52252,7 @@ def _emit_service_status_loop(): def _emit_watchlist_count_loop(): """Background thread that pushes watchlist count every 10 seconds to each profile room.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(10) try: database = get_database() @@ -52118,7 +52265,7 @@ def _emit_watchlist_count_loop(): def _emit_download_status_loop(): """Background thread that pushes download batch status every 2 seconds to subscribed rooms.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(2) try: live_transfers_lookup = get_cached_transfer_data() @@ -52179,7 +52326,7 @@ def handle_profile_join(data): def _emit_system_stats_loop(): """Background thread that pushes system stats every 10 seconds.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(10) try: socketio.emit('dashboard:stats', _build_system_stats()) @@ -52188,7 +52335,7 @@ def _emit_system_stats_loop(): def _emit_activity_feed_loop(): """Background thread that pushes activity feed every 2 seconds.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(2) try: with activity_feed_lock: @@ -52199,7 +52346,7 @@ def _emit_activity_feed_loop(): def _emit_db_stats_loop(): """Background thread that pushes database stats every 10 seconds.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(10) try: db = get_database() @@ -52210,7 +52357,7 @@ def _emit_db_stats_loop(): def _emit_wishlist_count_loop(): """Background thread that pushes wishlist count every 10 seconds to each profile room.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(10) try: from core.wishlist_service import get_wishlist_service @@ -52256,7 +52403,7 @@ def _emit_rate_monitor_loop(): 'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment', } - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(1) try: from core.api_call_tracker import api_call_tracker @@ -52326,7 +52473,7 @@ def _emit_enrichment_status_loop(): 'genius-enrichment': lambda: genius_worker, } - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(2) # Auto-pause/resume rate-limited workers during downloads @@ -52364,7 +52511,7 @@ def _emit_enrichment_status_loop(): def _emit_tool_progress_loop(): """Background thread that pushes all tool progress statuses every 1 second.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(1) # Stream status try: @@ -52452,7 +52599,7 @@ def handle_discovery_unsubscribe(data): def _emit_sync_progress_loop(): """Push sync progress to subscribed rooms every 1 second.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(1) try: with sync_lock: @@ -52476,7 +52623,7 @@ def _emit_discovery_progress_loop(): 'listenbrainz': lambda: listenbrainz_playlist_states, 'spotify_public': lambda: spotify_public_discovery_states, } - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(1) for platform, get_states in platform_states.items(): try: @@ -52506,7 +52653,7 @@ def _emit_discovery_progress_loop(): def _emit_scan_status_loop(): """Push watchlist and media scan status every 2 seconds.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(2) # Watchlist scan try: @@ -52538,7 +52685,7 @@ def _emit_scan_status_loop(): def _emit_automation_progress_loop(): """Push automation:progress events every 1 second for running automations.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(1) try: with automation_progress_lock: @@ -52582,7 +52729,7 @@ def _emit_automation_progress_loop(): def _emit_repair_progress_loop(): """Push repair:progress events every 1 second for running repair jobs.""" - while True: + while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(1) try: if repair_worker is None: @@ -52626,12 +52773,6 @@ def _emit_repair_progress_loop(): if __name__ == '__main__': - # Initialize logging for web server - from utils.logging_config import setup_logging - log_level = config_manager.get('logging.level', 'INFO') - log_path = config_manager.get('logging.path', 'logs/app.log') - logger = setup_logging(log_level, log_path) - print("Starting SoulSync Web UI Server...") print("Open your browser and navigate to http://127.0.0.1:8008") diff --git a/webui/index.html b/webui/index.html index 0d2b677b..2c128e55 100644 --- a/webui/index.html +++ b/webui/index.html @@ -329,7 +329,7 @@