From 7a8cc854db604629285bdb877c6b3acefffb8f25 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 12 Apr 2026 11:45:53 +0300 Subject: [PATCH 01/22] Setup logging early to prevent initial logs from being swallowed There's a lot side-effects happening at import-time (eg. client initialization etc.), some of which include meaningful logging as well (eg. migration-related logging in MusicDatabase client). If we delay the logging initialization to the __main__ block, we'll lose out on these early logs --- web_server.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/web_server.py b/web_server.py index 08bdcf8e..a11c0ebe 100644 --- a/web_server.py +++ b/web_server.py @@ -21,15 +21,17 @@ 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" @@ -52560,12 +52562,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") From 5a40c185e1984f4d68c307ac77dd3d8db41a3992 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 12 Apr 2026 11:57:56 +0300 Subject: [PATCH 02/22] Remove lingering local db file --- database/music_library.db | Bin 40960 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 database/music_library.db diff --git a/database/music_library.db b/database/music_library.db deleted file mode 100644 index fc442bc7b77c33b0bbc88df55a491bf3d902fabd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40960 zcmeI(T~E_c7{KvWVPhDL7beTX&AFtBcw@YGj)OtRkgh~D(WcO8l4h#Fr)|4jyM_w?bM-}AKWB+%RG1d%VE9EJlo64xu2D~3_IDMY1G zsjJtrdSzRDypSEJ@5aRbal7@(wflE2s(0(P#mc4N!q<9p>C4jN+PB*0+MYVah5!Nx zAb`NX6WFO&8*5GDc`)?+SAqA+i9+|_`N)ZasPF6j<+deTwiKJ)b-5>IW5jk>=s2;O zTx9Mn&7^eu`{O~HQt#L2QclLiDfMxd(yFPHD-)x1!zdU^XOjhmFtFE&5yoUb*SP2$lcGnK+k$1O=-7ob!1-mWl^vN zp6VD|-j0aA%%g-@TN8tz-w#Iq!Qs#w6;AX?(D$7eZuBe}O>ye|Kn+8E zFirJ}gAPJp#dwbT?-b`4zwmxL$HulLH*a_2 z(u&orjHa;UhEzjn%U*sb2c8+X+q&#X^)P9-dhOP_EFRc&(yZ9iS+je&FzuYZ`Tpo! z($gQelAhK#g8q7bs;Biiu{xI`Zt;l@{c%rcb-YYj9O!5 z#rW`6_nvND{k$EWo9@1>tqW`LGwm1ujyp{^{fS-~jRylaJUXHK{(iORK+Skn`oAQFW|{~ffB*srAb Date: Sun, 12 Apr 2026 12:51:52 +0300 Subject: [PATCH 03/22] Fix db initialization When starting from scratch (no existing .db file), certain db init steps were being skipped. Upon subsequent startup, these remaining 4-5 steps would execute. Up until recently this has not been much of an issue since all the db init steps were run repeatedly throughout the process' lifetime, but after the init was changed to be only done once per startup, this became more problematic --- database/music_database.py | 48 ++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 95b54646..f8271d30 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -977,12 +977,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 +1011,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 +1028,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 +1293,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 +1459,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 +1487,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 +1502,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 +1763,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 +2231,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 +2247,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 +2410,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 +2432,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 +2507,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 +2520,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 +2579,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 +2595,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) From aec3047216753a598d40c6849598c54655405f2f Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 12 Apr 2026 15:15:53 +0300 Subject: [PATCH 04/22] Improve graceful shutdown and rollback safety - Add interruptible stop events to background workers so shutdown wakes out of long sleeps instead of waiting on fixed delays. - Stop scan managers, repair worker, executors, and cleanup helpers deterministically so process exit does not leave background threads alive. - Add startup warnings for stale SQLite WAL/SHM sidecars so unclean shutdowns are easier to spot before init/migration errors cascade. - Prevent forced kills from leaving SQLite sidecars behind, which made rollbacks to older branches fail with malformed database errors. --- core/audiodb_worker.py | 14 +- core/deezer_worker.py | 14 +- core/discogs_worker.py | 14 +- core/genius_worker.py | 16 +- core/hydrabase_worker.py | 12 +- core/itunes_worker.py | 20 +- core/lastfm_worker.py | 16 +- core/listening_stats_worker.py | 19 +- core/media_scan_manager.py | 15 +- core/musicbrainz_worker.py | 14 +- core/plex_scan_manager.py | 19 +- core/qobuz_worker.py | 22 ++- core/repair_jobs/acoustid_scanner.py | 3 +- core/repair_jobs/base.py | 28 ++- core/repair_jobs/library_reorganize.py | 3 +- core/repair_jobs/mbid_mismatch_detector.py | 3 +- core/repair_jobs/metadata_gap_filler.py | 3 +- core/repair_jobs/unknown_artist_fixer.py | 3 +- core/repair_worker.py | 34 +++- core/soulid_worker.py | 20 +- core/spotify_worker.py | 28 +-- core/tidal_worker.py | 20 +- core/web_scan_manager.py | 51 ++++- core/worker_utils.py | 17 ++ database/music_database.py | 48 +++++ web_server.py | 206 +++++++++++++++++---- 26 files changed, 520 insertions(+), 142 deletions(-) create mode 100644 core/worker_utils.py 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_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..42648a51 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,7 +90,7 @@ class ListeningStatsWorker: logger.info("Listening stats worker thread started") # Build cache from existing data immediately (before first poll) - time.sleep(5) + interruptible_sleep(self._stop_event, 5) try: self._build_stats_cache() logger.info("Initial stats cache built from existing data") @@ -94,17 +98,17 @@ class ListeningStatsWorker: logger.debug(f"Initial cache build skipped: {e}") # Wait before first poll - time.sleep(10) + interruptible_sleep(self._stop_event, 10) 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 +123,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/media_scan_manager.py b/core/media_scan_manager.py index 28f083b3..451d5fed 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: @@ -250,8 +259,11 @@ class MediaScanManager: 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 @@ -360,6 +372,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 +382,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/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_scan_manager.py b/core/plex_scan_manager.py index 8c694284..76df7dd5 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: @@ -168,8 +177,11 @@ class PlexScanManager: 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 @@ -218,7 +230,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 +325,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 +335,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 17342528..28beaf5f 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") @@ -541,7 +545,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") @@ -614,7 +618,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/web_scan_manager.py b/core/web_scan_manager.py index 343d36b4..e13d2d72 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,14 +271,22 @@ 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""" @@ -295,4 +320,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..285ac243 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()) diff --git a/web_server.py b/web_server.py index 08bdcf8e..fabfeb88 100644 --- a/web_server.py +++ b/web_server.py @@ -2517,27 +2517,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: @@ -3206,6 +3220,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 +3326,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 +3374,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 +3460,52 @@ 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 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 +3516,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) From 29d964e8b0e06e2c88250191a35bbf4893450ba5 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 12 Apr 2026 17:36:15 +0300 Subject: [PATCH 05/22] Tighten up teardown logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop active DB update work before tearing down executor pools. - Short-circuit scan completion callbacks during shutdown so in-flight timer ticks don’t queue follow-up work. - Prevent the download monitor from draining deferred/completed tasks after shutdown starts. - Make listening stats startup stop-aware so it exits cleanly if teardown begins during warmup. - If a metadata update is already running, it can now observe should_stop and exit cleanly instead of continuing after SIGTERM. --- core/listening_stats_worker.py | 9 +++++++-- core/media_scan_manager.py | 4 ++++ core/plex_scan_manager.py | 4 ++++ core/web_scan_manager.py | 22 ++++++++++++++-------- web_server.py | 14 +++++++++++++- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/core/listening_stats_worker.py b/core/listening_stats_worker.py index 42648a51..414f857a 100644 --- a/core/listening_stats_worker.py +++ b/core/listening_stats_worker.py @@ -90,15 +90,20 @@ class ListeningStatsWorker: logger.info("Listening stats worker thread started") # Build cache from existing data immediately (before first poll) - interruptible_sleep(self._stop_event, 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 - interruptible_sleep(self._stop_event, 10) + if interruptible_sleep(self._stop_event, 10): + return while not self.should_stop: try: diff --git a/core/media_scan_manager.py b/core/media_scan_manager.py index 451d5fed..f7661128 100644 --- a/core/media_scan_manager.py +++ b/core/media_scan_manager.py @@ -256,6 +256,8 @@ 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 @@ -268,6 +270,8 @@ class MediaScanManager: 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() diff --git a/core/plex_scan_manager.py b/core/plex_scan_manager.py index 76df7dd5..fd8af490 100644 --- a/core/plex_scan_manager.py +++ b/core/plex_scan_manager.py @@ -174,6 +174,8 @@ 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 @@ -186,6 +188,8 @@ class PlexScanManager: 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() diff --git a/core/web_scan_manager.py b/core/web_scan_manager.py index e13d2d72..d2110ae0 100644 --- a/core/web_scan_manager.py +++ b/core/web_scan_manager.py @@ -290,12 +290,19 @@ class WebScanManager: 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: @@ -308,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""" diff --git a/web_server.py b/web_server.py index fabfeb88..33f477c2 100644 --- a/web_server.py +++ b/web_server.py @@ -2638,6 +2638,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: @@ -3460,6 +3462,11 @@ def _shutdown_runtime_components(): except Exception as e: print(f"Error saving API call history: {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([ @@ -40789,6 +40796,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") # =============================== @@ -44903,7 +44911,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 @@ -44971,6 +44979,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 @@ -44979,12 +44988,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) From 7f5384a89e14202155033afbbdfd032af7b95a4e Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 12 Apr 2026 18:30:11 +0300 Subject: [PATCH 06/22] Add some more shutdown checks for websocket handlers --- web_server.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/web_server.py b/web_server.py index 33f477c2..f54d3836 100644 --- a/web_server.py +++ b/web_server.py @@ -22830,7 +22830,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) @@ -22857,6 +22857,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) @@ -52130,7 +52132,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 @@ -52180,7 +52182,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()) @@ -52189,7 +52191,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() @@ -52202,7 +52204,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() @@ -52263,7 +52265,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()) @@ -52272,7 +52274,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: @@ -52283,7 +52285,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() @@ -52294,7 +52296,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 @@ -52340,7 +52342,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 @@ -52410,7 +52412,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 @@ -52448,7 +52450,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: @@ -52536,7 +52538,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: @@ -52560,7 +52562,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: @@ -52590,7 +52592,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: @@ -52622,7 +52624,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: @@ -52666,7 +52668,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: From d77274c2ea96b1a3eb9fa908d8f565ee9c75cb87 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:27:17 -0700 Subject: [PATCH 07/22] Reject tracks with Unknown Artist from metadata cache The junk entity filter checked track name but not artist_name, allowing tracks like "Woman Like You by Unknown Artist" to be cached. Now rejects any track or album where artist_name matches the junk names list (unknown, unknown artist, empty, null, etc). Prevents stale incomplete data from persisting across retries. --- core/metadata_cache.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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.""" From d44c78c87b374e2243a0bb987749f1af9b7440b5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:31:42 -0700 Subject: [PATCH 08/22] Fix music library paths not auto-saving on settings page Dynamic music path inputs were created after auto-save listeners were attached, so typing in them never triggered a save. Now attaches change listeners when creating or rendering path rows. Removing a path also triggers auto-save immediately. --- webui/static/script.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/webui/static/script.js b/webui/static/script.js index a954a9e6..c317dee4 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -6704,9 +6704,13 @@ function renderMusicPaths(paths) { container.innerHTML = paths.map((p, i) => `
- +
`).join(''); + // Attach auto-save to dynamically rendered inputs + container.querySelectorAll('.music-path-input').forEach(input => { + input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); }); + }); } function addMusicPathRow() { @@ -6720,10 +6724,19 @@ function addMusicPathRow() { row.style.marginBottom = '4px'; row.innerHTML = ` - + `; container.appendChild(row); - row.querySelector('input').focus(); + const input = row.querySelector('input'); + input.focus(); + // Auto-save when the user finishes typing a path + input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); }); +} + +function _removeMusicPathRow(btn) { + btn.closest('.music-path-row').remove(); + // Auto-save after removing a path + if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); } function collectMusicPaths() { From 1c1be6190a05acce09a1c96a17811d26ada3cd11 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:43:51 -0700 Subject: [PATCH 09/22] Fix Plex playlists crash on Tag objects and add Unknown Artist guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plex API can return Tag objects mixed with playlists — these lack the playlistType attribute, causing AttributeError. Use getattr with safe default instead of direct attribute access. Add 3-tier Unknown Artist guard in post-processing: checks track_info artists, original search result, then re-fetches from metadata API before building folder paths or embedding tags. Prevents files from landing in Unknown Artist folders when the download context has incomplete artist data. --- core/plex_client.py | 8 +++---- web_server.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) 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/web_server.py b/web_server.py index 08bdcf8e..2a9bb96f 100644 --- a/web_server.py +++ b/web_server.py @@ -20092,6 +20092,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) From 027549be59c1b84655d2ad2bb4ad30a74f943ffc Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:48:24 -0700 Subject: [PATCH 10/22] Fix album matching using full similarity instead of word subset check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old subset check treated "Paradise" as matching "Club Paradise" because {'paradise'} is a subset of {'club', 'paradise'}. Both got the same +0.10 bonus, so the wrong album could be selected. Now uses SequenceMatcher for full-string similarity between the wanted album name and each path segment. Exact matches (>= 0.85) get +0.10, partial matches (>= 0.60) get +0.03, no match gets +0.00. No penalty applied — purely adjusts bonus sizing so the correct album ranks higher. --- core/matching_engine.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) 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 From a7877e6e0b67e1e6eb82f3d4be6c8ead90bd9382 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:55:06 -0700 Subject: [PATCH 11/22] Skip albums with placeholder track names in watchlist scanner Spotify lists unreleased albums with placeholder names like "Track 1", "Track 2" before the real tracklist is revealed. The scanner was trying to download these, searching Soulseek for "Track 1" by artist which matches random files. Now skips any album where more than half the tracks match the placeholder pattern. Covers both the watchlist scan and discovery pool paths. --- core/watchlist_scanner.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index a841a71d..98c66c9d 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. @@ -2128,6 +2150,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: From 55f0532f307c34d8eec0f6659c7b126a2cf0960d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:01:15 -0700 Subject: [PATCH 12/22] Fix M3U files created for non-playlist downloads Single track downloads from Search, album downloads, redownloads, and issue downloads were not in the M3U skip list, so auto-save M3U created playlist files for them. Expanded skip list to cover all non-playlist prefixes: enhanced_search_track_, issue_download_, library_redownload_, and redownload_. --- webui/static/script.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/webui/static/script.js b/webui/static/script.js index c317dee4..920bfe47 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -12121,9 +12121,13 @@ async function autoSavePlaylistM3U(playlistId) { const m3uContent = generateM3UContent(playlistId); if (!m3uContent) return; - // Skip M3U for albums — albums are already naturally grouped in media servers - const albumPrefixes = ['artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_']; - if (albumPrefixes.some(p => playlistId.startsWith(p))) return; + // Skip M3U for non-playlist downloads — albums, singles, redownloads, etc. + const nonPlaylistPrefixes = [ + 'artist_album_', 'discover_album_', 'enhanced_search_album_', 'enhanced_search_track_', + 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_', + 'issue_download_', 'library_redownload_', 'redownload_', + ]; + if (nonPlaylistPrefixes.some(p => playlistId.startsWith(p))) return; const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; const artistName = process.artist?.name || ''; From 3d26c2b14002b20692b8935c5a88a8bb71ce49a8 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:10:11 -0700 Subject: [PATCH 13/22] Fix [object Object] in M3U files and database reference error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3U generator was calling .join() on an array of artist objects instead of extracting .name first, producing "[object Object] - Track Name". Now handles all artist formats: array of objects, array of strings, single string, single object. Also fix "name 'database' is not defined" error when updating album year in post-processing — was using bare 'database' instead of get_database() helper. --- web_server.py | 2 +- webui/static/script.js | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/web_server.py b/web_server.py index 2a9bb96f..da61ec24 100644 --- a/web_server.py +++ b/web_server.py @@ -18403,7 +18403,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(""" diff --git a/webui/static/script.js b/webui/static/script.js index 920bfe47..3f24cf2d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -12183,7 +12183,14 @@ function generateM3UContent(playlistId) { tracks.forEach((track, index) => { const durationSeconds = track.duration_ms ? Math.floor(track.duration_ms / 1000) : -1; - const artists = Array.isArray(track.artists) ? track.artists.join(', ') : (track.artists || 'Unknown Artist'); + let artists = 'Unknown Artist'; + if (Array.isArray(track.artists)) { + artists = track.artists.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : String(a)).filter(Boolean).join(', ') || 'Unknown Artist'; + } else if (typeof track.artists === 'string') { + artists = track.artists; + } else if (track.artist) { + artists = typeof track.artist === 'object' ? (track.artist.name || 'Unknown Artist') : String(track.artist); + } // Check library match status from the modal UI const matchEl = document.getElementById(`match-${playlistId}-${index}`); From 501140104e8b50fb9075882129c9f53ffcf4e8b9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:43:06 -0700 Subject: [PATCH 14/22] Fill interactive help gaps for all new UI elements Added ~30 HELPER_CONTENT entries covering: - Sidebar: Downloads page, Playlist Explorer nav buttons - Dashboard: all 10 enrichment service worker pills, recent syncs, API rate monitor, maintenance, SoulID, blacklist - Sync: Server Playlists tab, ListenBrainz tab - Active Downloads page: filters, list, clear button - Playlist Explorer page: picker, view toggle, action bar - Issues page: header, filters, findings list - Discover: Your Artists carousel - Personal Settings gear button --- webui/static/helper.js | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/webui/static/helper.js b/webui/static/helper.js index 27ddd288..20ae0007 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -100,6 +100,24 @@ const HELPER_CONTENT = { ], docsId: 'library' }, + '.nav-button[data-page="active-downloads"]': { + title: 'Downloads', + description: 'Centralized view of every download across the entire app. Shows live status for all tracks from Sync, Discover, Artists, Search, and Wishlist in one place.', + tips: [ + 'Filter by status: Active, Queued, Completed, Failed', + 'Badge on the nav button shows active download count from any page', + 'Clear Completed button removes finished items from the list' + ] + }, + '.nav-button[data-page="playlist-explorer"]': { + title: 'Playlist Explorer', + description: 'Visual exploration tool for playlists. Browse album art grids or full discographies from any playlist source. Select tracks to add to wishlist or download directly.', + tips: [ + 'Toggle between Albums view and Full Discog view', + 'Select multiple tracks across albums for batch operations', + 'Works with Spotify, Tidal, Deezer, and ListenBrainz playlists' + ] + }, '.nav-button[data-page="stats"]': { title: 'Library Statistics', description: 'Detailed analytics — genre breakdowns, format distribution, quality analysis, collection growth, and enrichment coverage across all metadata services.', @@ -724,6 +742,20 @@ const HELPER_CONTENT = { tips: ['Every parsed playlist is automatically mirrored here', 'Cards show live state: Discovering, Discovered, Syncing, Complete', 'Re-parsing the same URL updates the existing mirror'], docsId: 'sync-mirrored' }, + '.sync-tab-button[data-tab="server"]': { + title: 'Server Playlists', + description: 'View and manage playlists from your connected media server (Plex, Jellyfin, or Navidrome). Compare server-side playlists with source playlists to find differences.', + tips: [ + 'Two-column layout: source playlist vs server playlist', + 'Disambiguation overlay helps match tracks when names differ', + 'Useful for verifying sync completeness against your media server' + ] + }, + '.sync-tab-button[data-tab="listenbrainz"]': { + title: 'ListenBrainz Playlists', + description: 'Import playlists from ListenBrainz — community-generated playlists, weekly discoveries, and your own ListenBrainz playlists.', + tips: ['Paste any ListenBrainz playlist URL', 'Supports weekly exploration and community playlists', 'Tracks are resolved via MusicBrainz recording IDs'], + }, // Sync page header & history '.sync-history-btn': { @@ -2092,6 +2124,81 @@ const HELPER_CONTENT = { description: 'Save all settings changes. Some changes take effect immediately; others require a restart.', }, + // ─── DASHBOARD: ENRICHMENT SERVICES ──────────────────────────── + + '#enrichment-pills-section': { + title: 'Enrichment Service Workers', + description: 'Per-service enrichment workers that run in the background to enrich your library metadata. Each button shows the worker status and lets you start/stop individual services.', + tips: [ + 'Green = running, grey = stopped, red = error', + 'Click a service pill to toggle its worker on/off', + 'Workers process tracks in batches — hover for detailed stats' + ] + }, + '#musicbrainz-button': { + title: 'MusicBrainz Enrichment', + description: 'Looks up recording IDs, release groups, and artist MBIDs from MusicBrainz. Provides canonical identifiers used by other services.', + }, + '#audiodb-button': { + title: 'AudioDB Enrichment', + description: 'Adds artist bios, band member info, genre tags, and high-res artwork from TheAudioDB.', + }, + '#deezer-button': { + title: 'Deezer Enrichment', + description: 'Enriches tracks with Deezer IDs, BPM data, and genre information from the Deezer catalog.', + }, + '#spotify-enrich-button': { + title: 'Spotify Enrichment', + description: 'Links tracks to Spotify IDs for popularity scores, audio features, and cross-referencing. Requires Spotify OAuth connection.', + }, + '#itunes-enrich-button': { + title: 'iTunes Enrichment', + description: 'Matches tracks to the Apple Music/iTunes catalog for genre tags and iTunes IDs.', + }, + '#lastfm-enrich-button': { + title: 'Last.fm Enrichment', + description: 'Adds Last.fm listener/play counts and community genre tags to your library tracks.', + }, + '#genius-enrich-button': { + title: 'Genius Enrichment', + description: 'Links tracks to Genius for lyrics availability and song descriptions.', + }, + '#tidal-enrich-button': { + title: 'Tidal Enrichment', + description: 'Matches tracks to the Tidal catalog for Tidal IDs and lossless availability info.', + }, + '#qobuz-enrich-button': { + title: 'Qobuz Enrichment', + description: 'Links tracks to Qobuz for Hi-Res availability data and Qobuz IDs.', + }, + '#discogs-button': { + title: 'Discogs Enrichment', + description: 'Enriches with Discogs data — detailed genre/style taxonomy (400+ tags), label info, catalog numbers, and community ratings.', + }, + + // ─── DASHBOARD: RECENT SYNCS & RATE MONITOR ────────────────────── + + '#sync-history-cards': { + title: 'Recent Syncs', + description: 'Quick view of your most recent playlist sync operations. Shows playlist name, track counts, and completion status.', + }, + '#rate-monitor-section': { + title: 'API Rate Monitor', + description: 'Live view of API rate limit usage across all metadata services. Shows remaining quota, cooldown timers, and ban status.', + }, + '#repair-button': { + title: 'Library Maintenance', + description: 'Open the maintenance panel to run repair jobs — detect orphan files, fix missing covers, clean live recordings, reorganize files, and more.', + }, + '#soulid-button': { + title: 'SoulID Generator', + description: 'Generate unique fingerprint IDs for your audio files using AcoustID. Useful for deduplication and cross-referencing.', + }, + '#blacklist-card': { + title: 'Download Blacklist', + description: 'Sources that have been blocked from future downloads. Tracks from blacklisted sources will be skipped during search and matching.', + }, + // ─── DASHBOARD: ACTIVITY FEED ─────────────────────────────────── '#dashboard-activity-feed': { @@ -2103,6 +2210,92 @@ const HELPER_CONTENT = { 'The feed persists across page navigation within the session' ] }, + + // ─── ACTIVE DOWNLOADS PAGE ────────────────────────────────────── + + '.adl-container': { + title: 'Downloads', + description: 'Live view of every download happening across the app. Tracks from Search, Sync, Discover, Artists, and Wishlist all appear here in one unified list.', + }, + '#adl-filter-pills': { + title: 'Download Filters', + description: 'Filter downloads by status. "All" shows everything, "Active" shows currently downloading/searching tracks, "Queued" shows waiting tracks, "Completed" and "Failed" show finished items.', + }, + '#adl-list': { + title: 'Download List', + description: 'Each row shows track title, artist, album, which batch it belongs to (playlist name or album), and current status. Active downloads show a spinner, completed show green, failed show red with error details.', + tips: [ + 'Track position (e.g. "3 of 19") shows progress within album/playlist batches', + 'Section headers group downloads by status category', + 'List updates every 2 seconds while you\'re on this page' + ] + }, + '#adl-clear-btn': { + title: 'Clear Completed', + description: 'Remove all completed, failed, and cancelled downloads from the list. Only affects the tracker display — does not delete any downloaded files.', + }, + + // ─── PLAYLIST EXPLORER PAGE ────────────────────────────────────── + + '#playlist-explorer-page': { + title: 'Playlist Explorer', + description: 'Visual exploration tool for deep-diving into playlists. Browse album art grids, explore full artist discographies, and batch-select tracks for download or wishlist.', + tips: [ + 'Pick a playlist source (Spotify, Tidal, Deezer, ListenBrainz) and select a playlist', + 'Albums view shows album art cards; Full Discog view shows complete artist discographies', + 'Select tracks across multiple albums, then use the action bar to download or wishlist them all' + ] + }, + '#explorer-playlist-picker': { + title: 'Playlist Picker', + description: 'Choose which playlist to explore. Select a source tab, then pick a playlist from the dropdown.', + }, + '.explorer-mode-btn': { + title: 'View Mode Toggle', + description: 'Switch between Albums view (grouped by album with artwork) and Full Discog view (complete discography for each artist in the playlist).', + }, + '#explorer-build-btn': { + title: 'Explore Playlist', + description: 'Load the selected playlist and build the visual explorer view. Fetches album art and track listings from your metadata source.', + }, + '#explorer-action-bar': { + title: 'Selection Action Bar', + description: 'Appears when tracks are selected. Shows selection count and provides batch actions — add to wishlist or download all selected tracks.', + }, + + // ─── ISSUES PAGE ──────────────────────────────────────────────── + + '.issues-header': { + title: 'Issues & Findings', + description: 'Library health scanner results. Each finding is a detected problem — missing files, duplicate tracks, incomplete albums, bad metadata, and more.', + }, + '#issues-filters': { + title: 'Issue Filters', + description: 'Filter findings by category (Missing Files, Duplicates, Metadata Gaps, etc.), severity, or job type. Helps focus on the most important issues first.', + }, + '#issues-list': { + title: 'Findings List', + description: 'Each row is a detected issue with details, severity, and available actions. Click "Fix" to auto-repair, "Dismiss" to hide, or expand for more details.', + tips: [ + 'Green "Fix" button applies the suggested repair automatically', + 'Dismissed findings are hidden but can be restored from filters', + 'Run repair jobs from Settings > Maintenance to generate new findings' + ] + }, + + // ─── DISCOVER PAGE: ADDITIONAL ───────────────────────────────── + + '#your-artists-section': { + title: 'Your Artists', + description: 'Carousel of artists from your watchlist. Quick access to view their latest releases, discography, or manage watchlist settings.', + }, + + // ─── PERSONAL SETTINGS ───────────────────────────────────────── + + '#personal-settings-btn': { + title: 'My Settings', + description: 'Personal settings for your profile — accent color, home page preference, notification preferences, and other per-user customizations.', + }, }; // ── Docs Navigation Helper ─────────────────────────────────────────────── From 445cb242f216985350942a79fec38f354d9757c3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:52:06 -0700 Subject: [PATCH 15/22] Use Deezer contributors field for multi-artist track tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deezer's API returns a contributors array with all credited artists on a track, but only the primary artist field was used. Now extracts all contributor names into the artists array for feature/collab tracks. Only affects the per-track ARTIST tag (TPE1) — album artist, folder paths, and matching are unchanged. Falls back to single primary artist when contributors field is absent (search results) or has only one entry. --- core/deezer_client.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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, From 48eff57ed09329a702cb1ef228b02137c194fba7 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:06:29 -0700 Subject: [PATCH 16/22] =?UTF-8?q?Fix=20sync=20tab=20content=20clipping=20?= =?UTF-8?q?=E2=80=94=20enable=20vertical=20scrolling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync-tab-content had overflow:hidden which clipped long content like the file import preview table and server playlist editor. Changed to overflow-y:auto so all sync tabs scroll when content exceeds the container height. --- webui/static/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webui/static/style.css b/webui/static/style.css index c5e0d300..9a507f0b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11894,7 +11894,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { flex-direction: column; gap: 15px; flex-grow: 1; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .sync-tab-content.active { From 26b2ca60fc98ac729faa9d288bb3966d0018c683 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:43:18 -0700 Subject: [PATCH 17/22] Bump to v2.3, sticky sidebar header, compact idle player, Plex fixes Version bump to 2.3 with rewritten What's New modal covering all changes since v2.2. Docker publish workflow default updated. Sidebar improvements: - Header stays pinned at top while nav and player scroll beneath it - Media player collapses to compact single-line when no track is playing, expands to full size when playback starts Fixes: - Server playlists endpoint Plex Tag object crash (getattr fix) - Server playlists tab auto-refreshes after download completion - Fixed dead code syntax error in archived version notes --- .github/workflows/docker-publish.yml | 2 +- web_server.py | 238 ++++++++++----------------- webui/index.html | 4 +- webui/static/script.js | 15 +- webui/static/style.css | 32 +++- 5 files changed, 134 insertions(+), 157 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0ebaae8d..c4b2c2f3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,7 +6,7 @@ on: version_tag: description: 'Version tag (e.g. 1.6, 1.7)' required: true - default: '2.2' + default: '2.3' jobs: build-and-push: diff --git a/web_server.py b/web_server.py index 70274234..ebaa5083 100644 --- a/web_server.py +++ b/web_server.py @@ -34,7 +34,7 @@ _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 @@ -21493,180 +21493,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", @@ -22320,9 +22260,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": [ @@ -30558,7 +30496,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, 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 @@
- +
@@ -6597,7 +6597,7 @@

What's New in SoulSync

-
Version 2.1 — Latest Changes
+
Version 2.3 — Latest Changes
diff --git a/webui/static/script.js b/webui/static/script.js index 3f24cf2d..33294adb 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -3043,6 +3043,10 @@ function initializeMediaPlayer() { const stopButton = document.getElementById('stop-button'); const volumeSlider = document.getElementById('volume-slider'); + // Start in idle state (no track playing) + const player = document.getElementById('media-player'); + if (player && !currentTrack) player.classList.add('idle'); + // Initialize HTML5 audio player audioPlayer = document.getElementById('audio-player'); if (audioPlayer) { @@ -3129,8 +3133,9 @@ function setTrackInfo(track) { document.getElementById('play-button').disabled = false; document.getElementById('stop-button').disabled = false; - // Hide no track message + // Hide no track message and expand player document.getElementById('no-track-message').classList.add('hidden'); + document.getElementById('media-player').classList.remove('idle'); // Sync expanded player and media session updateNpTrackInfo(); @@ -3205,8 +3210,9 @@ function clearTrack() { // Hide loading animation hideLoadingAnimation(); - // Show no track message + // Show no track message and collapse player document.getElementById('no-track-message').classList.remove('hidden'); + document.getElementById('media-player').classList.add('idle'); // Reset queue state npQueue = []; @@ -15585,6 +15591,11 @@ function processModalStatusUpdate(playlistId, data) { const completionMessage = `Download complete! ${completionParts.join(', ')}.`; showToast(completionMessage, 'success'); + // Refresh server playlists tab so it reflects newly synced tracks + if (typeof loadServerPlaylists === 'function') { + setTimeout(() => loadServerPlaylists(), 2000); + } + // Auto-close wishlist modal when completed (for auto-processing) if (playlistId === 'wishlist') { console.log('🔄 [Auto-Wishlist] Auto-closing completed wishlist modal to enable next cycle'); diff --git a/webui/static/style.css b/webui/static/style.css index 9a507f0b..242457a1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -259,7 +259,7 @@ body { rgba(var(--accent-rgb), 0.14) 0%, rgba(var(--accent-rgb), 0.08) 30%, rgba(var(--accent-rgb), 0.03) 70%, - transparent 100%); + rgba(18, 18, 18, 1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-top-right-radius: 20px; padding: 20px 24px; @@ -267,8 +267,11 @@ body { flex-direction: column; justify-content: center; gap: 8px; - position: relative; + position: sticky; + top: 0; + z-index: 10; overflow: hidden; + flex-shrink: 0; /* Subtle inner glow */ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); @@ -536,6 +539,31 @@ body { inset 0 1px 0 rgba(255, 255, 255, 0.1); } +/* Compact idle state — collapsed when nothing is playing */ +.media-player.idle { + min-height: 0; + padding: 0; + margin: 4px 14px; +} + +.media-player.idle .player-top-progress, +.media-player.idle .media-header, +.media-player.idle .media-controls-row, +.media-player.idle .media-expanded { + display: none; +} + +.media-player.idle .no-track-message { + padding: 10px 14px; + font-size: 0.75rem; + gap: 8px; +} + +.media-player.idle .no-track-message svg { + width: 18px; + height: 18px; +} + /* Top progress bar - thin line across full width */ .player-top-progress { padding: 0 14px; From 45ba51ce3cb4485447928207d7eec698051b59ce Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:26:21 -0700 Subject: [PATCH 18/22] Add diacritic-insensitive matching to library artist search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_artists now uses unidecode_lower() and _normalize_for_comparison() so 'Tiesto' finds 'Tiësto'. Track search already had this — artist search was the only gap. No change to stored data, only the comparison. --- database/music_database.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 34affa00..2db8751d 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -4888,25 +4888,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() From e65f73abe2e16be3a3802906c533152adb960f85 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:41:55 -0700 Subject: [PATCH 19/22] Fix allow_duplicates setting not working in watchlist scanner The setting only affected wishlist dedup but the watchlist scanner's library check still skipped tracks by title+artist regardless. Now when allow_duplicates is enabled, the scanner compares album names and only skips if the same album matches. Same song on a different album is allowed through to the wishlist. --- core/watchlist_scanner.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 98c66c9d..312e9320 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1524,15 +1524,22 @@ 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) - + if db_track and confidence >= 0.7: + # When allow_duplicates is on, only skip if the same album matches + if allow_duplicates and album_name and db_track.album_title: + from difflib import SequenceMatcher + album_sim = SequenceMatcher(None, album_name.lower(), db_track.album_title.lower()).ratio() + if album_sim < 0.8: + logger.debug(f"Track found but different album (allow_duplicates=True): '{original_title}' — library: '{db_track.album_title}', wanted: '{album_name}'") + continue # Different album — allow it logger.debug(f"Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})") return False # Track exists in library From f9450b4fea16b5f59bcec14fff050103ba217637 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:33:08 -0700 Subject: [PATCH 20/22] Purge cached tracks with junk artist names on first v2.3 startup One-time migration deletes metadata cache entries where artist_name is null, empty, 'unknown', 'unknown artist', etc. The cache gate now prevents new junk entries, but existing poisoned data from before the fix needs cleaning so users don't keep getting Unknown Artist from stale cache hits. --- database/music_database.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/database/music_database.py b/database/music_database.py index 2db8751d..a07225d9 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -660,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") From 3a7c25f20f877402e1ccdaa983e71bca78c2a31f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:41:55 -0700 Subject: [PATCH 21/22] Fix allow_duplicates not working for singles in watchlist scanner Singles like "idol" weren't added when the same song existed on a different album because check_track_exists used album-aware matching that found the track via album name. Now skips the album hint when allow_duplicates is on so matching is title+artist only, then compares album names ourselves with a strict 0.85 threshold. Only affects users with allow_duplicates enabled. --- core/watchlist_scanner.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 312e9320..dedc93f2 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1530,16 +1530,23 @@ class WatchlistScanner: for artist_name in artists_to_search: for query_title in unique_title_variations: - 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 same album matches - if allow_duplicates and album_name and db_track.album_title: - from difflib import SequenceMatcher - album_sim = SequenceMatcher(None, album_name.lower(), db_track.album_title.lower()).ratio() - if album_sim < 0.8: - logger.debug(f"Track found but different album (allow_duplicates=True): '{original_title}' — library: '{db_track.album_title}', wanted: '{album_name}'") - continue # Different album — allow it + # 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 From b4cf8b4cc184bf377fecc74992fc0985f2bdfc3c Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:37:13 -0700 Subject: [PATCH 22/22] Fix allow_duplicates for same-track-ID across different albums The wishlist table has a UNIQUE constraint on spotify_track_id, so INSERT OR REPLACE silently overwrote the existing entry when the same track appeared on a different album (same Spotify track ID). Now uses a composite key (track_id::album_id) when allow_duplicates is on and the base track ID already exists, allowing both versions to coexist. Only affects users with allow_duplicates enabled. --- database/music_database.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index a07225d9..eba95e57 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -6486,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()