Merge branch 'main' into main

This commit is contained in:
FelixClements 2026-04-13 06:44:09 +02:00 committed by GitHub
commit 59f0713261
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1184 additions and 382 deletions

View file

@ -11,6 +11,7 @@ on:
branches:
- main
- master
default: '2.3'
jobs:
build-and-push:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import time
from typing import Dict, Any
from utils.logging_config import get_logger
from core.worker_utils import interruptible_sleep
logger = get_logger("listening_stats_worker")
@ -32,6 +33,7 @@ class ListeningStatsWorker:
self.should_stop = False
self.thread = None
self.current_item = None
self._stop_event = threading.Event()
# Stats
self.stats = {
@ -52,6 +54,7 @@ class ListeningStatsWorker:
return
self.running = True
self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
logger.info("Listening stats worker started")
@ -61,8 +64,9 @@ class ListeningStatsWorker:
return
self.should_stop = True
self.running = False
self._stop_event.set()
if self.thread:
self.thread.join(timeout=5)
self.thread.join(timeout=1)
logger.info("Listening stats worker stopped")
def pause(self):
@ -86,25 +90,30 @@ class ListeningStatsWorker:
logger.info("Listening stats worker thread started")
# Build cache from existing data immediately (before first poll)
time.sleep(5)
if interruptible_sleep(self._stop_event, 5):
return
try:
self._build_stats_cache()
logger.info("Initial stats cache built from existing data")
except Exception as e:
logger.debug(f"Initial cache build skipped: {e}")
if self.should_stop:
return
# Wait before first poll
time.sleep(10)
if interruptible_sleep(self._stop_event, 10):
return
while not self.should_stop:
try:
if self.paused:
time.sleep(5)
interruptible_sleep(self._stop_event, 5)
continue
# Check if enabled
if not self.config_manager.get('listening_stats.enabled', True):
time.sleep(30)
interruptible_sleep(self._stop_event, 30)
continue
# Update poll interval from config
@ -119,12 +128,13 @@ class ListeningStatsWorker:
for _ in range(int(self.poll_interval)):
if self.should_stop:
break
time.sleep(1)
if interruptible_sleep(self._stop_event, 1):
break
except Exception as e:
logger.error(f"Error in listening stats worker: {e}", exc_info=True)
self.stats['errors'] += 1
time.sleep(60)
interruptible_sleep(self._stop_event, 60)
self.current_item = None
logger.info("Listening stats worker thread finished")

View file

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

View file

@ -39,6 +39,7 @@ class MediaScanManager:
self._periodic_update_timer = None # Timer for 5-minute periodic updates
self._periodic_update_interval = 300 # 5 minutes in seconds
self._is_doing_periodic_updates = False # Track if we're in periodic update mode
self._shutting_down = False
logger.info(f"MediaScanManager initialized with {delay_seconds}s debounce delay")
@ -119,6 +120,9 @@ class MediaScanManager:
"""
logger.info(f"DEBUG: Media scan requested - reason: {reason}")
with self._lock:
if self._shutting_down:
logger.debug("Media scan request ignored during shutdown")
return
if self._scan_in_progress:
# Server is currently scanning - mark that we need another scan later
self._downloads_during_scan = True
@ -134,6 +138,7 @@ class MediaScanManager:
# Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan)
self._timer.daemon = True
self._timer.start()
def add_scan_completion_callback(self, callback):
@ -164,6 +169,9 @@ class MediaScanManager:
def _execute_scan(self):
"""Execute the actual media library scan"""
with self._lock:
if self._shutting_down:
logger.debug("Media scan execution skipped during shutdown")
return
if self._scan_in_progress:
logger.warning("Scan already in progress - skipping duplicate execution")
return
@ -211,6 +219,7 @@ class MediaScanManager:
# Schedule first periodic update after 5 minutes
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.daemon = True
self._periodic_update_timer.start()
except Exception as e:
@ -247,15 +256,22 @@ class MediaScanManager:
if is_scanning:
# Still scanning - trigger database update and continue periodic updates
logger.info(f"{server_type.upper()} still scanning - triggering database update")
if self._shutting_down:
return
self._call_completion_callbacks()
# Schedule next periodic update
if self._shutting_down:
return
logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.daemon = True
self._periodic_update_timer.start()
else:
# Scanning stopped - final update and cleanup
logger.info(f"{server_type.upper()} scanning completed - doing final database update")
if self._shutting_down:
return
self._call_completion_callbacks()
self._stop_periodic_updates()
@ -360,6 +376,7 @@ class MediaScanManager:
def shutdown(self):
"""Clean shutdown - cancel any pending timers"""
with self._lock:
self._shutting_down = True
if self._timer:
self._timer.cancel()
self._timer = None
@ -369,4 +386,4 @@ class MediaScanManager:
self._periodic_update_timer = None
self._is_doing_periodic_updates = False
logger.info("MediaScanManager shutdown - cancelled all pending timers")
logger.info("MediaScanManager shutdown - cancelled all pending timers")

View file

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

View file

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

View file

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

View file

@ -39,6 +39,7 @@ class PlexScanManager:
self._periodic_update_timer = None # Timer for 5-minute periodic updates
self._periodic_update_interval = 300 # 5 minutes in seconds
self._is_doing_periodic_updates = False # Track if we're in periodic update mode
self._shutting_down = False
logger.info(f"PlexScanManager initialized with {delay_seconds}s debounce delay")
@ -51,6 +52,9 @@ class PlexScanManager:
"""
logger.info(f"DEBUG: Plex scan requested - reason: {reason}")
with self._lock:
if self._shutting_down:
logger.debug("Plex scan request ignored during shutdown")
return
if self._scan_in_progress:
# Plex is currently scanning - mark that we need another scan later
self._downloads_during_scan = True
@ -66,6 +70,7 @@ class PlexScanManager:
# Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan)
self._timer.daemon = True
self._timer.start()
def add_scan_completion_callback(self, callback):
@ -96,6 +101,9 @@ class PlexScanManager:
def _execute_scan(self):
"""Execute the actual Plex library scan"""
with self._lock:
if self._shutting_down:
logger.debug("Plex scan execution skipped during shutdown")
return
if self._scan_in_progress:
logger.warning("Scan already in progress - skipping duplicate execution")
return
@ -136,6 +144,7 @@ class PlexScanManager:
# Schedule first periodic update after 5 minutes
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.daemon = True
self._periodic_update_timer.start()
except Exception as e:
@ -165,15 +174,22 @@ class PlexScanManager:
if is_scanning:
# Still scanning - trigger database update and continue periodic updates
logger.info("Plex still scanning - triggering database update")
if self._shutting_down:
return
self._call_completion_callbacks()
# Schedule next periodic update
if self._shutting_down:
return
logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.daemon = True
self._periodic_update_timer.start()
else:
# Scanning stopped - final update and cleanup
logger.info("Plex scanning completed - doing final database update")
if self._shutting_down:
return
self._call_completion_callbacks()
self._stop_periodic_updates()
@ -218,7 +234,9 @@ class PlexScanManager:
if is_scanning:
# Still scanning, poll again in 30 seconds
logger.info("DEBUG: Plex library still scanning, will check again in 30 seconds")
threading.Timer(30, self._poll_scan_status).start()
timer = threading.Timer(30, self._poll_scan_status)
timer.daemon = True
timer.start()
else:
# Scan completed!
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
@ -311,6 +329,7 @@ class PlexScanManager:
def shutdown(self):
"""Clean shutdown - cancel any pending timers"""
with self._lock:
self._shutting_down = True
if self._timer:
self._timer.cancel()
self._timer = None
@ -320,4 +339,4 @@ class PlexScanManager:
self._periodic_update_timer = None
self._is_doing_periodic_updates = False
logger.info("PlexScanManager shutdown - cancelled all pending timers")
logger.info("PlexScanManager shutdown - cancelled all pending timers")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ from datetime import datetime, date, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.spotify_client import SpotifyClient, SpotifyRateLimitError
from core.worker_utils import interruptible_sleep
logger = get_logger("spotify_worker")
@ -31,6 +32,7 @@ class SpotifyWorker:
self.paused = False
self.should_stop = False
self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip)
self.current_item = None
@ -66,6 +68,7 @@ class SpotifyWorker:
return
self.running = True
self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
logger.info("Spotify background worker started")
@ -76,8 +79,9 @@ class SpotifyWorker:
logger.info("Stopping Spotify worker...")
self.should_stop = True
self.running = False
self._stop_event.set()
if self.thread:
self.thread.join(timeout=5)
self.thread.join(timeout=1)
logger.info("Spotify worker stopped")
def pause(self):
@ -167,7 +171,7 @@ class SpotifyWorker:
while not self.should_stop:
try:
if self.paused:
time.sleep(1)
interruptible_sleep(self._stop_event, 1)
continue
# Rate limit guard — if globally rate limited, sleep until ban expires
@ -175,7 +179,7 @@ class SpotifyWorker:
info = self.client.get_rate_limit_info()
remaining = info['remaining_seconds'] if info else 60
logger.debug(f"Spotify globally rate limited, sleeping {remaining}s...")
time.sleep(min(remaining, 60)) # Check again every 60s max
interruptible_sleep(self._stop_event, min(remaining, 60)) # Check again every 60s max
continue
# Daily budget guard — worker-only cap to avoid saturating Spotify rate limits
@ -184,7 +188,7 @@ class SpotifyWorker:
resets_in = budget['resets_in_seconds']
logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), "
f"resets in {resets_in // 3600}h {(resets_in % 3600) // 60}m")
time.sleep(min(resets_in, 300)) # Check every 5 min max
interruptible_sleep(self._stop_event, min(resets_in, 300)) # Check every 5 min max
continue
# Post-ban cooldown guard — after ban expires, wait before resuming
@ -192,7 +196,7 @@ class SpotifyWorker:
cooldown = self.client.get_post_ban_cooldown_remaining()
if cooldown > 0:
logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...")
time.sleep(min(cooldown, 60))
interruptible_sleep(self._stop_event, min(cooldown, 60))
continue
# Auth guard — check if Spotify client is configured (no API call).
@ -203,7 +207,7 @@ class SpotifyWorker:
self.client.reload_config()
if not self.client.is_spotify_authenticated():
logger.debug("Spotify not authenticated, sleeping 30s...")
time.sleep(30)
interruptible_sleep(self._stop_event, 30)
continue
self.current_item = None
@ -211,7 +215,7 @@ class SpotifyWorker:
if not item:
logger.debug("No pending items, sleeping...")
time.sleep(10)
interruptible_sleep(self._stop_event, 10)
continue
self.current_item = item
@ -229,14 +233,14 @@ class SpotifyWorker:
self._process_item(item)
self._increment_daily_budget()
time.sleep(self.inter_item_sleep)
interruptible_sleep(self._stop_event, self.inter_item_sleep)
except SpotifyRateLimitError:
logger.debug("Spotify rate limit hit in worker loop, will retry after ban expires")
time.sleep(10)
interruptible_sleep(self._stop_event, 10)
except Exception as e:
logger.error(f"Error in worker loop: {e}")
time.sleep(5)
interruptible_sleep(self._stop_event, 5)
self.current_item = None
logger.info("Spotify worker thread finished")
@ -542,7 +546,7 @@ class SpotifyWorker:
self._mark_status('album', db_id, 'not_found')
self.stats['not_found'] += 1
time.sleep(self.batch_inter_item_sleep)
interruptible_sleep(self._stop_event, self.batch_inter_item_sleep)
logger.info(f"Album batch for '{artist_name}': {matched_count}/{len(db_albums)} matched")
@ -615,7 +619,7 @@ class SpotifyWorker:
self._mark_status('track', db_id, 'not_found')
self.stats['not_found'] += 1
time.sleep(self.batch_inter_item_sleep)
interruptible_sleep(self._stop_event, self.batch_inter_item_sleep)
logger.info(f"Track batch for '{album_name}': {matched_count}/{len(db_tracks)} matched")

View file

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

View file

@ -807,6 +807,12 @@ class WatchlistScanner:
logger.debug(f"Skipping {release_type}: {album_data.get('name', 'Unknown')} - user preference")
continue
# Skip albums with placeholder track names (unreleased tracklist)
# Spotify uses "Track 1", "Track 2", etc. for unannounced tracks
if self._has_placeholder_tracks(tracks):
logger.info(f"Skipping album with placeholder tracks (unreleased tracklist): {album_data.get('name', 'Unknown')}")
continue
# Check each track
for track in tracks:
# Check content type filters (live, remix, acoustic, compilation)
@ -1349,6 +1355,22 @@ class WatchlistScanner:
except Exception:
return False # Error — assume released
def _has_placeholder_tracks(self, tracks: list) -> bool:
"""Check if an album's tracks are mostly placeholders (unreleased/unannounced tracklist).
Spotify uses 'Track 1', 'Track 2', etc. for tracks whose names haven't been revealed."""
if not tracks or len(tracks) == 0:
return False
import re
placeholder_count = 0
for track in tracks:
name = track.get('name', '') if isinstance(track, dict) else getattr(track, 'name', '')
# Match "Track 1", "Track 2", ..., "Track 99" (case-insensitive)
if re.match(r'^track\s+\d+$', name.strip(), re.IGNORECASE):
placeholder_count += 1
# If more than half the tracks are placeholders, skip the album
# (some albums legitimately have a track called "Track X" but not most of them)
return placeholder_count > len(tracks) / 2
def _should_include_release(self, track_count: int, watchlist_artist: WatchlistArtist) -> bool:
"""
Check if a release should be included based on user's preferences.
@ -1502,15 +1524,29 @@ class WatchlistScanner:
unique_title_variations = list(dict.fromkeys(title_variations))
# Search for each artist with each title variation
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
allow_duplicates = config_manager.get('wishlist.allow_duplicate_tracks', True)
for artist_name in artists_to_search:
for query_title in unique_title_variations:
# Use same database check as modals with server awareness
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=album_name)
# When allow_duplicates is on, skip album hint so we get title+artist matches only
search_album = None if allow_duplicates else album_name
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=search_album)
if db_track and confidence >= 0.7:
# When allow_duplicates is on, only skip if the exact same album
if allow_duplicates and album_name:
lib_album = getattr(db_track, 'album_title', '') or ''
if lib_album:
from difflib import SequenceMatcher
album_sim = SequenceMatcher(None, album_name.lower(), lib_album.lower()).ratio()
if album_sim < 0.85:
logger.debug(f"Track found but different album (allow_duplicates=True): '{original_title}' — library: '{lib_album}', wanted: '{album_name}'")
continue # Different album — allow it
else:
# No album info in library — can't compare, allow it
continue
logger.debug(f"Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})")
return False # Track exists in library
@ -2128,6 +2164,11 @@ class WatchlistScanner:
logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)")
# Skip albums with placeholder tracks (unreleased tracklist)
if self._has_placeholder_tracks(tracks):
logger.info(f" Skipping album with placeholder tracks: {album_data.get('name', 'Unknown')}")
continue
# Determine if this is a new release (within last 30 days)
is_new = False
try:

View file

@ -38,6 +38,8 @@ class WebScanManager:
self._max_scan_time = 1800 # 30 minutes maximum
self._current_server_type = None
self._scan_progress = {}
self._completion_check_timer = None
self._shutting_down = False
logger.info(f"WebScanManager initialized with {delay_seconds}s debounce delay")
@ -84,6 +86,14 @@ class WebScanManager:
logger.info(f"Web scan requested - reason: {reason}")
with self._lock:
if self._shutting_down:
logger.debug("Web scan request ignored during shutdown")
return {
"status": "ignored",
"message": "Server is shutting down",
"delay_seconds": 0,
"reason": reason,
}
# Add callback if provided
if callback and callback not in self._scan_completion_callbacks:
self._scan_completion_callbacks.append(callback)
@ -107,6 +117,7 @@ class WebScanManager:
# Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan)
self._timer.daemon = True
self._timer.start()
return {
@ -169,6 +180,9 @@ class WebScanManager:
def _execute_scan(self):
"""Execute the actual media library scan"""
with self._lock:
if self._shutting_down:
logger.debug("Web scan execution skipped during shutdown")
return
if self._scan_in_progress:
logger.warning("Web scan already in progress - skipping duplicate execution")
return
@ -231,6 +245,9 @@ class WebScanManager:
"""Start periodic checking for scan completion"""
def check_completion():
try:
if self._shutting_down:
logger.debug("Web scan completion check aborted during shutdown")
return
# Check for timeout
if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time:
logger.warning(f"Web scan timed out after {self._max_scan_time} seconds")
@ -254,23 +271,38 @@ class WebScanManager:
self._handle_scan_completion()
else:
# Continue checking
threading.Timer(30, check_completion).start() # Check every 30 seconds
if self._shutting_down:
return
timer = threading.Timer(30, check_completion) # Check every 30 seconds
timer.daemon = True
self._completion_check_timer = timer
timer.start()
except Exception as e:
logger.error(f"Error during web scan completion check: {e}")
self._reset_scan_state()
# Start first check after 30 seconds
threading.Timer(30, check_completion).start()
timer = threading.Timer(30, check_completion)
timer.daemon = True
self._completion_check_timer = timer
timer.start()
def _handle_scan_completion(self):
"""Handle scan completion and trigger callbacks"""
logger.info(f"Web {self._current_server_type.upper()} library scan completed")
# Call completion callbacks
callbacks_to_call = []
with self._lock:
if self._shutting_down:
return
server_type = self._current_server_type
callbacks_to_call = self._scan_completion_callbacks.copy()
downloads_during_scan = self._downloads_during_scan
if not server_type:
logger.debug("Skipping web scan completion: no active server type")
self._reset_scan_state()
return
logger.info(f"Web {server_type.upper()} library scan completed")
for callback in callbacks_to_call:
try:
@ -283,10 +315,9 @@ class WebScanManager:
self._reset_scan_state()
# Check if we need another scan due to downloads during this scan
with self._lock:
if self._downloads_during_scan:
logger.info("Web scan follow-up needed for downloads during scan")
self.request_scan("Follow-up scan for downloads during previous scan")
if downloads_during_scan:
logger.info("Web scan follow-up needed for downloads during scan")
self.request_scan("Follow-up scan for downloads during previous scan")
def _reset_scan_state(self):
"""Reset internal scan state"""
@ -295,4 +326,24 @@ class WebScanManager:
self._current_server_type = None
self._scan_start_time = None
self._scan_progress = {}
# Don't clear callbacks - they might be reused
# 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")

17
core/worker_utils.py Normal file
View file

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

View file

@ -16,6 +16,7 @@ from utils.logging_config import get_logger
logger = get_logger("music_database")
_database_initialized_paths = set()
_database_sidecar_warnings = set()
_database_initialization_lock = threading.Lock()
# Import matching engine for enhanced similarity logic
@ -170,10 +171,57 @@ class MusicDatabase:
database_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
self.database_path = Path(database_path)
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._warn_about_stale_sqlite_sidecars()
# Initialize database once per process for this path
self._initialize_database_once()
def _warn_about_stale_sqlite_sidecars(self):
"""Warn if SQLite sidecars are present and the database looks unhealthy."""
db_key = str(self.database_path.resolve())
with _database_initialization_lock:
if db_key in _database_sidecar_warnings:
return
_database_sidecar_warnings.add(db_key)
wal_path = Path(f"{self.database_path}-wal")
shm_path = Path(f"{self.database_path}-shm")
existing = [p.name for p in (wal_path, shm_path) if p.exists()]
if existing:
check_result = None
try:
conn = sqlite3.connect(f"file:{self.database_path}?mode=ro", uri=True, timeout=5.0)
try:
row = conn.execute("PRAGMA quick_check").fetchone()
check_result = row[0] if row else None
finally:
conn.close()
except Exception as e:
logger.warning(
"SQLite sidecar files detected for %s: %s, and database health check could not be run (%s). "
"This usually means the previous shutdown was not clean.",
self.database_path,
", ".join(existing),
e,
)
return
if check_result != "ok":
logger.warning(
"SQLite sidecar files detected for %s: %s, and quick_check returned %r. "
"This usually means the previous shutdown was not clean.",
self.database_path,
", ".join(existing),
check_result,
)
else:
logger.debug(
"SQLite sidecar files present for %s (%s) but quick_check returned ok.",
self.database_path,
", ".join(existing),
)
def _initialize_database_once(self):
"""Run schema setup and migrations once per database path per process."""
db_key = str(self.database_path.resolve())
@ -612,6 +660,23 @@ class MusicDatabase:
except Exception:
pass
# One-time migration: purge cached tracks/albums with junk artist names.
# The cache gate now rejects these, but existing entries need cleaning.
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_cache_junk_artist_purged'")
if not cursor.fetchone():
cursor.execute("""DELETE FROM metadata_cache_entities
WHERE entity_type IN ('track', 'album')
AND (artist_name IS NULL
OR TRIM(artist_name) = ''
OR LOWER(TRIM(artist_name)) IN ('unknown', 'unknown artist', 'none', 'null'))""")
purged = cursor.rowcount
cursor.execute("CREATE TABLE _cache_junk_artist_purged (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
if purged > 0:
logger.info(f"Purged {purged} cached tracks/albums with junk artist names")
except Exception:
pass
conn.commit()
logger.info("Database initialized successfully")
@ -977,12 +1042,15 @@ class MusicDatabase:
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN artist_genres TEXT")
logger.info("Added artist_genres column to discovery_pool table")
if 'source' not in discovery_pool_columns:
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN source TEXT DEFAULT 'spotify'")
logger.info("Added source column to discovery_pool table")
# Migration: Add iTunes columns to discovery_pool for dual-source discovery
if 'itunes_track_id' not in discovery_pool_columns:
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_track_id TEXT")
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_album_id TEXT")
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_artist_id TEXT")
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN source TEXT DEFAULT 'spotify'")
logger.info("Added iTunes columns to discovery_pool table for dual-source discovery")
# Migration: Add Deezer columns to discovery_pool for tri-source discovery
@ -1008,9 +1076,12 @@ class MusicDatabase:
cursor.execute("PRAGMA table_info(recent_releases)")
recent_releases_columns = [column[1] for column in cursor.fetchall()]
if 'source' not in recent_releases_columns:
cursor.execute("ALTER TABLE recent_releases ADD COLUMN source TEXT DEFAULT 'spotify'")
logger.info("Added source column to recent_releases table")
if 'album_itunes_id' not in recent_releases_columns:
cursor.execute("ALTER TABLE recent_releases ADD COLUMN album_itunes_id TEXT")
cursor.execute("ALTER TABLE recent_releases ADD COLUMN source TEXT DEFAULT 'spotify'")
logger.info("Added iTunes columns to recent_releases table for dual-source discovery")
# Migration: Add Deezer column to recent_releases for tri-source discovery
@ -1022,10 +1093,13 @@ class MusicDatabase:
cursor.execute("PRAGMA table_info(discovery_recent_albums)")
discovery_recent_albums_columns = [column[1] for column in cursor.fetchall()]
if 'source' not in discovery_recent_albums_columns:
cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'")
logger.info("Added source column to discovery_recent_albums table")
if 'album_itunes_id' not in discovery_recent_albums_columns:
cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN album_itunes_id TEXT")
cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN artist_itunes_id TEXT")
cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'")
logger.info("Added iTunes columns to discovery_recent_albums table for dual-source discovery")
# Migration: Add Deezer columns to discovery_recent_albums for tri-source discovery
@ -1284,7 +1358,7 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)")
logger.info("Discovery tables created successfully")
logger.info("Discovery tables added/verified successfully")
except Exception as e:
logger.error(f"Error creating discovery tables: {e}")
@ -1450,6 +1524,8 @@ class MusicDatabase:
include_remixes INTEGER DEFAULT 0,
include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0,
include_instrumentals INTEGER DEFAULT 0,
lookback_days INTEGER DEFAULT NULL,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT,
@ -1476,6 +1552,8 @@ class MusicDatabase:
include_remixes INTEGER DEFAULT 0,
include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0,
include_instrumentals INTEGER DEFAULT 0,
lookback_days INTEGER DEFAULT NULL,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT
@ -1489,6 +1567,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
'include_albums', 'include_eps', 'include_singles', 'include_live',
'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days',
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_cols)
@ -1749,7 +1828,7 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_discogs_id ON albums (discogs_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_discogs_status ON albums (discogs_match_status)")
logger.info("Discogs enrichment columns added/verified")
logger.info("Discogs enrichment columns added/verified successfully")
except Exception as e:
logger.error(f"Error adding Discogs columns: {e}")
@ -2217,6 +2296,8 @@ class MusicDatabase:
include_remixes INTEGER DEFAULT 0,
include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0,
include_instrumentals INTEGER DEFAULT 0,
lookback_days INTEGER DEFAULT NULL,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT,
@ -2231,6 +2312,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
'include_albums', 'include_eps', 'include_singles', 'include_live',
'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days',
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in col_names]
cols_str = ', '.join(shared_cols)
@ -2393,6 +2475,9 @@ class MusicDatabase:
itunes_track_id TEXT,
itunes_album_id TEXT,
itunes_artist_id TEXT,
deezer_track_id TEXT,
deezer_album_id TEXT,
deezer_artist_id TEXT,
source TEXT NOT NULL DEFAULT 'spotify',
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
@ -2412,6 +2497,7 @@ class MusicDatabase:
new_cols = ['id', 'spotify_track_id', 'spotify_album_id', 'spotify_artist_id',
'itunes_track_id', 'itunes_album_id', 'itunes_artist_id',
'deezer_track_id', 'deezer_album_id', 'deezer_artist_id',
'source', 'track_name', 'artist_name', 'album_name', 'album_cover_url',
'duration_ms', 'popularity', 'release_date', 'is_new_release',
'track_data_json', 'artist_genres', 'added_date', 'profile_id']
@ -2486,6 +2572,7 @@ class MusicDatabase:
watchlist_artist_id INTEGER NOT NULL,
album_spotify_id TEXT,
album_itunes_id TEXT,
album_deezer_id TEXT,
source TEXT NOT NULL DEFAULT 'spotify',
album_name TEXT NOT NULL,
release_date TEXT NOT NULL,
@ -2498,8 +2585,8 @@ class MusicDatabase:
""")
new_cols = ['id', 'watchlist_artist_id', 'album_spotify_id', 'album_itunes_id',
'source', 'album_name', 'release_date', 'album_cover_url',
'track_count', 'added_date', 'profile_id']
'album_deezer_id', 'source', 'album_name', 'release_date',
'album_cover_url', 'track_count', 'added_date', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_cols)
@ -2557,10 +2644,15 @@ class MusicDatabase:
source_artist_id TEXT NOT NULL,
similar_artist_spotify_id TEXT,
similar_artist_itunes_id TEXT,
similar_artist_deezer_id TEXT,
similar_artist_name TEXT NOT NULL,
similarity_rank INTEGER DEFAULT 1,
occurrence_count INTEGER DEFAULT 1,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
image_url TEXT,
genres TEXT,
popularity INTEGER DEFAULT 0,
metadata_updated_at TIMESTAMP,
last_featured TIMESTAMP,
profile_id INTEGER DEFAULT 1,
UNIQUE(profile_id, source_artist_id, similar_artist_name)
@ -2568,9 +2660,10 @@ class MusicDatabase:
""")
new_cols = ['id', 'source_artist_id', 'similar_artist_spotify_id',
'similar_artist_itunes_id', 'similar_artist_name',
'similarity_rank', 'occurrence_count', 'last_updated',
'last_featured', 'profile_id']
'similar_artist_itunes_id', 'similar_artist_deezer_id',
'similar_artist_name', 'similarity_rank', 'occurrence_count',
'last_updated', 'image_url', 'genres', 'popularity',
'metadata_updated_at', 'last_featured', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_cols)
@ -4812,25 +4905,27 @@ class MusicDatabase:
return []
def search_artists(self, query: str, limit: int = 50, server_source: str = None) -> List[DatabaseArtist]:
"""Search artists by name, optionally filtered by server source."""
"""Search artists by name, optionally filtered by server source.
Uses diacritic-insensitive matching so 'Tiesto' finds 'Tiësto'."""
try:
conn = self._get_connection()
cursor = conn.cursor()
norm_query = f"%{self._normalize_for_comparison(query)}%"
if server_source:
cursor.execute("""
SELECT * FROM artists
WHERE name LIKE ? AND server_source = ?
WHERE unidecode_lower(name) LIKE ? AND server_source = ?
ORDER BY name
LIMIT ?
""", (f"%{query}%", server_source, limit))
""", (norm_query, server_source, limit))
else:
cursor.execute("""
SELECT * FROM artists
WHERE name LIKE ?
WHERE unidecode_lower(name) LIKE ?
ORDER BY name
LIMIT ?
""", (f"%{query}%", limit))
""", (norm_query, limit))
rows = cursor.fetchall()
@ -6391,12 +6486,33 @@ class MusicDatabase:
spotify_json = json.dumps(spotify_track_data)
source_json = json.dumps(source_info or {})
# No duplicate found, insert the track
# When allow_duplicates is on, make the key unique per album so the same
# track from different albums can coexist in the wishlist
insert_track_id = track_id
if allow_duplicates:
album_obj = spotify_track_data.get('album', {})
album_id = album_obj.get('id', '') if isinstance(album_obj, dict) else ''
if album_id:
# Check if this exact track+album combo already exists
composite_id = f"{track_id}::{album_id}"
cursor.execute("SELECT id FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?",
(composite_id, profile_id))
if cursor.fetchone():
logger.debug(f"Skipping wishlist entry — same track+album already in wishlist: '{track_name}' on '{album_obj.get('name', '')}'")
return False
# Check if base track_id exists (from a different album)
cursor.execute("SELECT id FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?",
(track_id, profile_id))
if cursor.fetchone():
# Same track exists from different album — use composite ID
insert_track_id = composite_id
# Insert the track
cursor.execute("""
INSERT OR REPLACE INTO wishlist_tracks
(spotify_track_id, spotify_data, failure_reason, source_type, source_info, date_added, profile_id)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
""", (track_id, spotify_json, failure_reason, source_type, source_json, profile_id))
""", (insert_track_id, spotify_json, failure_reason, source_type, source_json, profile_id))
conn.commit()

Binary file not shown.

View file

@ -21,18 +21,20 @@ from urllib.parse import urljoin
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g
from flask_socketio import SocketIO, emit, join_room, leave_room
from utils.logging_config import get_logger
from utils.logging_config import get_logger, setup_logging
from utils.async_helpers import run_async
# --- Core Application Imports ---
# Import the same core clients and config manager used by the GUI app
from config.settings import config_manager
# Initialize logger
logger = get_logger("web_server")
# Setup logging early to avoid any import-time logs from being swallowed
_log_level = config_manager.get('logging.level', 'INFO')
_log_path = config_manager.get('logging.path', 'logs/app.log')
logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, version-info endpoint, etc.
SOULSYNC_VERSION = "2.2"
SOULSYNC_VERSION = "2.3"
# Dedicated source reuse logger — writes to logs/source_reuse.log
import logging as _logging
@ -2517,27 +2519,41 @@ class WebUIDownloadMonitor:
self.monitoring = False
self.monitor_thread = None
self.monitored_batches = set()
self._lock = threading.Lock()
def start_monitoring(self, batch_id):
"""Start monitoring a download batch"""
self.monitored_batches.add(batch_id)
if not self.monitoring:
self.monitoring = True
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.monitor_thread.start()
print(f"Started download monitor for batch {batch_id}")
with self._lock:
self.monitored_batches.add(batch_id)
if not self.monitoring:
self.monitoring = True
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.monitor_thread.start()
print(f"Started download monitor for batch {batch_id}")
def stop_monitoring(self, batch_id):
"""Stop monitoring a specific batch"""
self.monitored_batches.discard(batch_id)
if not self.monitored_batches:
with self._lock:
self.monitored_batches.discard(batch_id)
if not self.monitored_batches:
self.monitoring = False
print(f"Stopped download monitor (no active batches)")
def shutdown(self):
"""Stop the monitor loop and clear active batch tracking."""
with self._lock:
self.monitoring = False
print(f"Stopped download monitor (no active batches)")
self.monitored_batches.clear()
self.monitor_thread = None
print("Download monitor shutdown requested")
def _monitor_loop(self):
"""Main monitoring loop - checks downloads every 1 second for responsive web UX"""
while self.monitoring and self.monitored_batches:
try:
if globals().get('IS_SHUTTING_DOWN', False):
self.monitoring = False
break
self._check_all_downloads()
time.sleep(1) # 1-second polling for fast web UI updates
except Exception as e:
@ -2624,6 +2640,8 @@ class WebUIDownloadMonitor:
completed_tasks.append((batch_id, task_id))
# ---- All work below runs WITHOUT tasks_lock held ----
if globals().get('IS_SHUTTING_DOWN', False) or not self.monitoring:
return
# Execute deferred operations from _should_retry_task (network calls, nested locks)
for op in deferred_ops:
@ -3206,6 +3224,8 @@ def validate_and_heal_batch_states():
This is the server-side equivalent of the frontend's worker count validation.
"""
try:
if globals().get('IS_SHUTTING_DOWN', False):
return
import time
current_time = time.time()
@ -3310,15 +3330,41 @@ def validate_and_heal_batch_states():
# Start periodic batch healing (every 30 seconds)
import threading
_batch_healing_timer = None
_batch_healing_timer_lock = threading.Lock()
def _schedule_batch_healing_timer(delay_seconds=30.0):
"""Schedule the next batch healing cycle."""
global _batch_healing_timer
if globals().get('IS_SHUTTING_DOWN', False):
return
timer = threading.Timer(delay_seconds, start_batch_healing_timer)
timer.daemon = True
with _batch_healing_timer_lock:
_batch_healing_timer = timer
timer.start()
def _cancel_batch_healing_timer():
"""Cancel the current batch healing timer if one exists."""
global _batch_healing_timer
with _batch_healing_timer_lock:
timer = _batch_healing_timer
_batch_healing_timer = None
if timer:
timer.cancel()
def start_batch_healing_timer():
"""Start periodic batch state validation and healing"""
try:
if globals().get('IS_SHUTTING_DOWN', False):
return
validate_and_heal_batch_states()
except Exception as e:
print(f"[Batch Healing Timer] Error: {e}")
finally:
# Schedule next healing cycle
threading.Timer(30.0, start_batch_healing_timer).start()
_schedule_batch_healing_timer(30.0)
# Start the healing timer when the server starts
start_batch_healing_timer()
@ -3332,35 +3378,85 @@ def cleanup_monitor():
"""Clean up background monitor on shutdown"""
if download_monitor.monitoring:
print("Flask shutdown detected, stopping download monitor...")
download_monitor.monitoring = False
download_monitor.monitored_batches.clear()
download_monitor.shutdown()
# Give the thread a moment to exit cleanly
time.sleep(0.5)
# Clean up batch locks to prevent memory leaks
with tasks_lock:
batch_locks.clear()
print("Cleaned up batch locks")
try:
acquired = tasks_lock.acquire(timeout=1.0)
if acquired:
try:
batch_locks.clear()
print("Cleaned up batch locks")
finally:
tasks_lock.release()
else:
print("Skipped batch lock cleanup - tasks_lock busy")
except Exception as e:
print(f"Error cleaning up batch locks: {e}")
# Global shutdown flag
IS_SHUTTING_DOWN = False
def signal_handler(signum, frame):
"""Handle SIGINT (Ctrl+C) and SIGTERM"""
global IS_SHUTTING_DOWN
print(f"Signal {signum} received, cleaning up...")
IS_SHUTTING_DOWN = True
cleanup_monitor()
# Stop automation engine
def _shutdown_executor(executor, name):
"""Shut down a ThreadPoolExecutor without waiting for long-running tasks."""
if executor is None:
return
try:
if automation_engine:
print("Stopping automation engine...")
automation_engine.stop()
print(f"Shutting down {name}...")
executor.shutdown(wait=False, cancel_futures=True)
except Exception as e:
print(f"Error stopping automation engine: {e}")
print(f"Error shutting down {name}: {e}")
# Persist API call history
def _stop_component(component, name, method_names=("stop", "shutdown")):
"""Call a best-effort stop method on a component if it has one."""
if component is None:
return
for method_name in method_names:
method = getattr(component, method_name, None)
if callable(method):
try:
print(f"Stopping {name}...")
method()
except Exception as e:
print(f"Error stopping {name}: {e}")
return
def _stop_components_parallel(components):
"""Stop multiple components concurrently and wait for all stop calls to finish."""
stop_threads = []
for component, name in components:
if component is None:
continue
thread = threading.Thread(
target=_stop_component,
args=(component, name),
name=f"shutdown-{name.replace(' ', '-')}",
)
thread.start()
stop_threads.append((name, thread))
for name, thread in stop_threads:
thread.join()
def _shutdown_runtime_components():
"""Best-effort shutdown for timers, monitors, workers, and executors."""
global IS_SHUTTING_DOWN
if IS_SHUTTING_DOWN:
return
IS_SHUTTING_DOWN = True
_cancel_batch_healing_timer()
cleanup_monitor()
_stop_component(web_scan_manager, "web scan manager")
_stop_component(automation_engine, "automation engine")
# Persist API call history before shutting down worker pools.
try:
from core.api_call_tracker import api_call_tracker
api_call_tracker.save()
@ -3368,13 +3464,57 @@ def signal_handler(signum, frame):
except Exception as e:
print(f"Error saving API call history: {e}")
# Shutdown executor to prevent new tasks
try:
print("Shutting down missing_download_executor...")
missing_download_executor.shutdown(wait=False, cancel_futures=True)
except Exception as e:
print(f"Error shutting down executor: {e}")
# Stop the active DB update worker before tearing down the executor it runs on.
# This lets an in-flight update observe should_stop and exit cleanly.
_stop_component(db_update_worker, "db update worker")
_stop_component(metadata_update_runtime_worker, "metadata update worker")
# Stop long-lived worker components in parallel so shutdown waits for the
# slowest worker instead of serially burning the timeout for each one.
_stop_components_parallel([
(mb_worker, "musicbrainz worker"),
(audiodb_worker, "audiodb worker"),
(discogs_worker, "discogs worker"),
(deezer_worker, "deezer worker"),
(spotify_enrichment_worker, "spotify enrichment worker"),
(itunes_enrichment_worker, "itunes enrichment worker"),
(lastfm_worker, "lastfm worker"),
(genius_worker, "genius worker"),
(tidal_enrichment_worker, "tidal enrichment worker"),
(qobuz_enrichment_worker, "qobuz enrichment worker"),
(hydrabase_worker, "hydrabase worker"),
(soulid_worker, "soulid worker"),
(listening_stats_worker, "listening stats worker"),
(repair_worker, "repair worker"),
])
# Shut down executor pools so their worker threads stop keeping the process alive.
for executor, name in [
(stream_executor, "stream executor"),
(db_update_executor, "db update executor"),
(quality_scanner_executor, "quality scanner executor"),
(duplicate_cleaner_executor, "duplicate cleaner executor"),
(retag_executor, "retag executor"),
(sync_executor, "sync executor"),
(missing_download_executor, "missing download executor"),
(tidal_discovery_executor, "tidal discovery executor"),
(deezer_discovery_executor, "deezer discovery executor"),
(spotify_public_discovery_executor, "spotify public discovery executor"),
(youtube_discovery_executor, "youtube discovery executor"),
(beatport_discovery_executor, "beatport discovery executor"),
(listenbrainz_discovery_executor, "listenbrainz discovery executor"),
(similar_artists_executor, "similar artists executor"),
(metadata_update_executor, "metadata update executor"),
]:
_shutdown_executor(executor, name)
# Give daemon cleanup threads a moment to observe the shutdown flag.
time.sleep(0.2)
def signal_handler(signum, frame):
"""Handle SIGINT (Ctrl+C) and SIGTERM"""
print(f"Signal {signum} received, cleaning up...")
_shutdown_runtime_components()
sys.exit(0)
# Register cleanup handlers
@ -3385,7 +3525,14 @@ def _atexit_save_history():
except Exception:
pass
def _atexit_shutdown():
try:
_shutdown_runtime_components()
except Exception:
pass
atexit.register(_atexit_save_history)
atexit.register(_atexit_shutdown)
atexit.register(cleanup_monitor)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
@ -18444,7 +18591,7 @@ def _embed_source_ids(audio_file, metadata: dict):
_pp_album_name = metadata.get('album', '')
_pp_artist_name = metadata.get('album_artist', '') or metadata.get('artist', '')
if _pp_album_name and _pp_artist_name:
conn = database._get_connection()
conn = get_database()._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
@ -20133,6 +20280,61 @@ def _post_process_matched_download(context_key, context, file_path):
print(f"Post-processing failed: Missing spotify_artist context.")
return
# ── UNKNOWN ARTIST GUARD ──
# If artist name is junk, attempt to resolve from track metadata before proceeding.
# This prevents files from landing in "Unknown Artist/" folders.
_junk_artist_names = {'', 'unknown', 'unknown artist', 'various artists', 'none', 'null'}
_artist_name = (spotify_artist.get('name', '') if isinstance(spotify_artist, dict) else '').strip()
if _artist_name.lower() in _junk_artist_names:
print(f"[Unknown Artist Guard] Artist name is '{_artist_name}' — attempting to resolve")
_resolved = False
track_info_guard = context.get("track_info", {}) or {}
original_search_guard = context.get("original_search_result", {}) or {}
# Try 1: Pull artist from track_info.artists
_ti_artists = track_info_guard.get('artists', [])
if isinstance(_ti_artists, list) and _ti_artists:
_first = _ti_artists[0]
_name = _first.get('name', '') if isinstance(_first, dict) else str(_first)
if _name and _name.strip().lower() not in _junk_artist_names:
spotify_artist['name'] = _name.strip()
print(f"[Unknown Artist Guard] Resolved from track_info.artists: '{_name}'")
_resolved = True
# Try 2: Pull from original_search_result
if not _resolved:
_os_artist = original_search_guard.get('artist') or original_search_guard.get('artist_name') or ''
if isinstance(_os_artist, str) and _os_artist.strip().lower() not in _junk_artist_names:
spotify_artist['name'] = _os_artist.strip()
print(f"[Unknown Artist Guard] Resolved from original_search_result: '{_os_artist}'")
_resolved = True
# Try 3: Re-fetch from metadata source using track ID
if not _resolved:
_track_id = track_info_guard.get('id') or track_info_guard.get('track_id') or ''
if _track_id:
try:
_fb_client = _get_metadata_fallback_client()
if hasattr(_fb_client, 'get_track_details'):
_details = _fb_client.get_track_details(str(_track_id))
if _details and isinstance(_details, dict):
_d_artists = _details.get('artists', [])
if isinstance(_d_artists, list) and _d_artists:
_d_first = _d_artists[0]
_d_name = _d_first.get('name', '') if isinstance(_d_first, dict) else str(_d_first)
if _d_name and _d_name.strip().lower() not in _junk_artist_names:
spotify_artist['name'] = _d_name.strip()
print(f"[Unknown Artist Guard] Resolved from metadata API: '{_d_name}'")
_resolved = True
except Exception as _guard_err:
print(f"[Unknown Artist Guard] Metadata re-fetch failed: {_guard_err}")
if not _resolved:
print(f"[Unknown Artist Guard] Could not resolve artist — proceeding with '{_artist_name}'")
context['spotify_artist'] = spotify_artist
# ── END UNKNOWN ARTIST GUARD ──
# Check if playlist folder mode is enabled (sync page playlists only)
track_info = context.get("track_info", {})
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
@ -21332,180 +21534,120 @@ def get_version_info():
"sections": [
{
"title": "Centralized Downloads Page",
"description": "Live view of every download across the entire app in one place",
"description": "Live view of every download across the entire app — tracks from Sync, Discover, Artists, Search, and Wishlist all in one place",
"features": [
"• New Downloads page in sidebar — shows all tracks from Sync, Discover, Artists, Search, and Wishlist",
"• Live-updating list with filter pills: All, Active, Queued, Completed, Failed",
"• Section headers group downloads by status with track counts",
"• Track position display (3 of 19) for album and playlist batches",
"• Album art, artist/album metadata, batch context, and error messages per row",
"• New Downloads page in sidebar with live-updating list and nav badge",
"• Filter pills: All, Active, Queued, Completed, Failed",
"• Track position (3 of 19), album art, batch context, and error details per row",
"• Clear Completed button removes finished items from the tracker",
"• Nav badge shows active download count from any page via WebSocket",
],
"usage_note": "Click Downloads in the sidebar to see all active and recent downloads. The badge updates in real-time from any page."
"usage_note": "Click Downloads in the sidebar. The badge updates in real-time from any page."
},
{
"title": "First-Run Setup Wizard",
"description": "New full-screen guided setup for first-time users",
"description": "Full-screen guided setup that walks new users through configuration and their first download",
"features": [
"• 7-step wizard: Welcome, Metadata Source, Download Source, Paths & Media Server, Add Artists, First Download, Done",
"• All 6 download sources available: Soulseek, YouTube, HiFi, Tidal, Qobuz, Deezer — with inline config and test buttons",
"• Path fields default to /app/downloads and /app/Transfer with lock/unlock for Docker users",
"• Media server connection (Plex/Jellyfin/Navidrome) with inline test",
"• Add artists to watchlist with live search — shows watchlist status, add/remove in place",
"• First download step searches metadata, finds best match, and downloads through the full pipeline",
"• All settings save to DB identically to the Settings page — no difference in behavior",
"• 7-step wizard: Welcome, Metadata Source, Download Source, Paths, Media Server, Add Artists, First Download",
"• All 6 download sources with inline config and test buttons",
"• Locked path defaults for Docker users (/app/downloads, /app/Transfer)",
"• Add artists to watchlist with live search and remove in place",
"• First download goes through the full matched download pipeline with metadata",
"• Auto-shows on fresh installs, skippable, all settings save to DB",
"• Done page with tips grid covering Sync, Wishlist, Automations, Notifications, Help, and Settings",
],
"usage_note": "Auto-shows for new users. Re-open anytime with ?setup=1 in the URL."
},
{
"title": "Graceful Shutdown & Stability",
"description": "Application now shuts down cleanly within 1 second instead of 60+",
"features": [
"• All background workers use interruptible sleep — respond to shutdown signals immediately",
"• Docker containers no longer force-kill, preventing SQLite WAL corruption",
"• Parallel component shutdown for scan managers, repair workers, executors",
"• Download monitor thread safety with proper locking",
"• Logging initialized early so import-time diagnostic messages are captured",
"• Database initialization fixed for fresh installs — all migrations run in a single cycle",
],
"usage_note": "Docker users: containers now stop gracefully within the default 10-second timeout."
},
{
"title": "Unknown Artist Prevention",
"description": "Multi-layer defense against tracks downloading as 'Unknown Artist'",
"features": [
"• Metadata cache now rejects tracks with junk artist names — prevents caching incomplete data",
"• Post-processing 3-tier fallback: check track_info, search result, then re-fetch from metadata API",
"• Files never land in 'Unknown Artist' folders — guard runs before folder creation and tag embedding",
],
},
{
"title": "Deezer Multi-Artist Tagging",
"description": "Feature tracks now tag all credited artists using Deezer's contributors field",
"features": [
"• ARTIST tag includes all contributors (e.g. 'Kraftklub, Domiziana') instead of just the primary",
"• Album artist tag unchanged — folder organization unaffected",
"• Falls back gracefully when contributors field is absent (search results)",
],
"usage_note": "Open with ?setup=1 URL parameter or openSetupWizard() from browser console. First-run auto-detection coming soon."
},
{
"title": "Music Videos — Search & Download from YouTube",
"description": "New Music Videos tab in enhanced and global search for finding and downloading music videos",
"description": "Music Videos tab in enhanced and global search",
"features": [
"• Music Videos pill tab alongside Spotify/Deezer/iTunes/Discogs in both search bars",
"• YouTube search returns video cards with 16:9 thumbnails, duration, channel name, view count",
"• Click any video to download — circular progress ring on thumbnail, green checkmark on completion",
"• Metadata matching — searches your primary source for clean artist/title before saving",
"• Saves to configurable Music Videos directory as Artist/Title-video.mp4 (Plex global folder format)",
"• Plex video type suffixes supported: -video, -lyrics, -live, -concert, -interview, -behindthescenes"
"• Video cards with thumbnails, duration, channel name, view count",
"• Click to download with progress ring — metadata matched before saving",
"• Saves to configurable Music Videos directory (Plex format)",
],
"usage_note": "Set your Music Videos directory in Settings > Downloads, then search for any artist in the search bar and click the Music Videos tab."
"usage_note": "Set your Music Videos directory in Settings > Downloads, then use the Music Videos tab in search."
},
{
"title": "Lidarr Download Source (Development)",
"description": "Use Lidarr as a download source for Usenet and torrent content",
"description": "7th download source for Usenet and torrent content via Lidarr",
"features": [
"• 7th download source alongside Soulseek, YouTube, Tidal, Qobuz, HiFi, and Deezer",
"• SoulSync handles discovery and matching, Lidarr handles downloading via its indexers",
"• Configure with just URL + API key in Settings > Downloads",
"• SoulSync handles discovery and matching, Lidarr handles downloading",
"• Configure with URL + API key in Settings > Downloads",
"• Available as standalone source or in Hybrid mode priority order",
"• Currently in development — basic album search and download flow functional"
],
"usage_note": "Requires a running Lidarr instance with configured indexers and download clients. Set Download Source to 'Lidarr Only (Development)' or add to Hybrid order."
"usage_note": "Requires a running Lidarr instance. Set Download Source to 'Lidarr Only (Development)' or add to Hybrid order."
},
{
"title": "Metadata Pipeline Overhaul — Fix Unknown Artist & Source Selection",
"description": "Major fix for tracks downloading as 'Unknown Artist' and Spotify being used when Deezer/iTunes was selected",
"title": "Matching & Quality Improvements",
"description": "Better album matching, per-track artist support, and placeholder detection",
"features": [
"• Fixed playlist pipeline (discover → sync → wishlist → download) losing artist, track number, and album year data",
"• All discovery workers now respect your configured primary metadata source instead of always using Spotify",
"• Centralized metadata source selection in core/metadata_service.py — one source of truth for all features",
"• Fixed Deezer metadata cache returning incomplete data (missing track_number, release_date) from search result cache",
"• Sync completion toast now shows which specific tracks failed to match (not just a count)",
"• New 'Fix Unknown Artists' maintenance job — scans library for Unknown Artist tracks and corrects metadata, tags, and file paths",
"• One-time migration purges stale discovery and Deezer cache entries on first startup after update"
"• Album matching uses full similarity instead of word subset — 'Paradise' no longer matches 'Club Paradise'",
"• Artist gate prevents wrong-artist downloads (Soulseek < 0.25, YouTube < 0.15 threshold)",
"• Word boundary matching for artists — 'muse' no longer matches 'museum'",
"• Watchlist scanner skips albums with placeholder tracks ('Track 1', 'Track 2') from unreleased tracklists",
"• Per-track artist column for compilations and DJ mixes — tag writer uses track artist, not album artist",
"• Centralized metadata source selection — all features respect your configured source",
],
"usage_note": "If you have existing Unknown Artist tracks, run the Fix Unknown Artists job from Settings > Maintenance."
},
{
"title": "Matching Engine — Artist Verification Gate",
"description": "Prevents downloading tracks from completely wrong artists on Soulseek and YouTube",
"features": [
"• New artist gate rejects candidates where the artist doesn't match the target (Soulseek: < 0.25, YouTube: < 0.15)",
"• Fixed artist substring matching — 'muse' no longer matches 'museum', 'art' no longer matches 'heart'",
"• Artist similarity now compared per path segment instead of full filename — misspelled artist names still match correctly",
"• YouTube artist weight increased from 10% to 20% to reduce wrong-uploader matches",
"• Seasonal discovery, personalized playlists, and playlist explorer all use configured source instead of Spotify"
],
"usage_note": "No action needed — matching improvements apply automatically to all new downloads."
},
{
"title": "Deezer User Playlists — Browse & Download Your Library",
"description": "New Deezer tab on the Sync page shows your personal playlists via ARL token — same flow as Spotify",
"features": [
"• Click Refresh to load all your Deezer playlists with track counts",
"• Click any playlist to view tracks, then Download Missing or Sync — no discovery step needed",
"• Existing Deezer URL import moved to 'Deezer Link' tab (unchanged)",
"• ARL token field added to Connections tab alongside Downloads tab with bidirectional sync",
"• Album release dates fetched for proper $year template variable support"
],
"usage_note": "Configure your ARL token in Settings > Connections or Downloads, then open the Deezer tab on the Sync page."
},
{
"title": "Qobuz Token Auth — CAPTCHA Bypass",
"description": "Qobuz added reCAPTCHA to their login — token auth lets you paste your session token directly",
"features": [
"• New 'Auth Token' field on both Connections and Downloads tabs for Qobuz",
"• Log into play.qobuz.com in your browser, copy X-User-Auth-Token from DevTools, paste it in",
"• Bypasses the CAPTCHA entirely — existing email/password login still works if your session is active",
"• Token is validated and saved as a normal session — identical to email/password login"
],
"usage_note": "If Qobuz email/password login fails, use the Auth Token field instead."
},
{
"title": "Streaming Source Matching — Artist Gate",
"description": "Tidal, Qobuz, HiFi, and Deezer downloads no longer match to wrong artists",
"features": [
"• Artist similarity gate rejects candidates below 0.4 match threshold",
"• Streaming source threshold raised from 0.55 to 0.60",
"• No more fallback to lenient Soulseek filename matcher for structured API sources",
"• Fixed single-char artist containment bug (e.g. 'B小町' no longer matches 'B.B. King')",
"• YouTube and Soulseek matching completely unchanged"
],
"usage_note": "Downloads from official sources are now much more accurate. Check Download History for verification details."
},
{
"title": "Download History — Source Provenance",
"description": "Collapsible download history with full source tracking and AcoustID verification badges",
"features": [
"• Expected vs Downloaded comparison — shows what you asked for vs what the source provided",
"• Mismatched downloads highlighted in red for easy identification",
"• AcoustID verification badge per entry: Verified (green), Failed (red), Skipped (orange), Off (gray)",
"• Source filename, track ID, and artist saved with every download",
"• Click anywhere on an entry to expand/collapse details"
],
"usage_note": "Click 'Download History' on the Dashboard to see source provenance for new downloads."
},
{
"title": "Fixes & Improvements",
"description": "Bug fixes, quality of life improvements, and new settings",
"description": "60+ commits of bug fixes, UX improvements, and infrastructure",
"features": [
"• Dismissed maintenance findings no longer reappear on next scan — dedup check now includes dismissed status",
"• Orphan file detector: increased path matching depth to 4 segments + filename parsing fallback for unreadable tags",
"• Media player: rapid play clicks no longer create duplicate audio streams requiring browser refresh",
"• Logs directory auto-created on startup — prevents crash for non-Docker installations",
"• Stale discovery data re-processed in automation pipeline — tracks with missing metadata get re-enriched",
"• Artist names no longer stored as lowercase — fixed static method shadowing instance method. Run a database update to fix existing names.",
"• Watchlist scanner skips future/unreleased albums — no more garbage downloads from albums not yet out",
"• Playlist sync tracks now tagged with correct track numbers instead of always 01",
"• Emby playlist sync fixed — integer IDs now accepted alongside Jellyfin GUIDs",
"• Discovery fix search now tries all metadata sources (Spotify → Deezer → iTunes) with automatic fallback",
"• Album completeness scanner skips zero-track albums to prevent auto-fill errors",
"• Global search string escaping fixed — albums with newlines in metadata no longer crash",
"• Download history timestamps fixed — no longer always showing 'Just now'",
"• Discogs added to enrichment service whitelist — Enrich button now works for Discogs",
"• Settings Connections tab redesigned with collapsible accordion services and brand-colored dots",
"• Metadata source filter on Library page — filter artists by matched/unmatched to any service",
"• Database Maintenance UI — VACUUM and incremental vacuum in Settings > Advanced",
"• Music Library Paths setting — configure where your music files live for tag writing and file detection",
"• Replace lower quality files on import — opt-in toggle in Settings > Library",
"• HiFi API instance health check in Settings > Downloads",
"• Debug test activity feed message removed from startup",
"• Global search downloads now create bubble snapshots on Dashboard and Search page",
"• Dead file findings now offer 'Remove from DB' option alongside 'Re-download' — works in bulk fix too",
"• Deezer ARL sync and download modals rehydrate after page refresh",
"• Deezer album data (release dates, cover art) cached in metadata cache — subsequent playlist loads are near-instant"
]
},
{
"title": "Artist Map — Visualize Your Music Universe",
"description": "Three interactive canvas-based visualization modes on the Discover page",
"features": [
"• Watchlist Constellation — your watched artists as large nodes with similar artists orbiting around them",
"• Genre Map — browse all artists by genre with a sidebar picker, ring-packed clusters, no artist cap",
"• Artist Explorer — deep-dive any artist, ring 1 (direct similar) + ring 2 (extended network)",
"• On-the-fly discovery — exploring an unknown artist fetches similar artists from MusicMap in real-time and caches them",
"• Invalid artist names validated against Spotify/iTunes before loading the map",
"• Offscreen canvas buffer rendering with LOD — handles 1000+ nodes smoothly",
"• Image proxy endpoint solves CORS for canvas — Deezer, Last.fm, Discogs images now render on bubbles",
"• Direct CORS fetch first (zero server load), proxy only as fallback for non-CORS CDNs",
"• Server-side 5-minute cache on all map endpoints — switching genres and reopening is instant",
"• Cache auto-invalidates on watchlist changes, scans, and new similar artist discoveries",
"• Keyboard shortcuts (?, F for fit, S for search), mouse wheel zoom, click-to-explore",
"• Hover constellation effect with fade animation, rich tooltips with genre tags"
"• Plex playlists crash on Tag objects fixed — safe attribute access for non-playlist items",
"• Music library paths now auto-save when added/removed on settings page",
"• M3U files no longer created for single track downloads — only playlists",
"• M3U [object Object] artist bug fixed — handles all artist format variations",
"• Album year update 'database not defined' error fixed in post-processing",
"• Sync tab content scrolling fixed — long lists no longer clipped",
"• Hybrid download status shows green when any serverless source is in the order",
"• Serverless sources (YouTube, HiFi, Qobuz) always show green in service status",
"• Repeated slskd 401 errors suppressed after first warning",
"• Download clients reload paths when settings change (no restart needed)",
"• watchlist_artists table migrations include all provider ID columns in rebuilds",
"• Emojis removed from all Python log and print statements",
"• Interactive help coverage expanded with 30+ new entries",
"• Docker compose includes optional slskd service block",
"• Multi-stage Docker build reduces image size",
"• MusicBrainz recording ID backfilled from Navidrome during scan",
],
"usage_note": "Navigate to Discover and click the Artist Map section. Choose Watchlist, Genre, or Explorer mode."
},
# v2.2 and earlier features moved to archive
]
}
return jsonify(version_data)
_OLD_V22_NOTES = """
{
"title": "Wing It — Download or Sync Without Discovery",
"description": "Bypass metadata discovery and use raw track names directly",
@ -22159,9 +22301,7 @@ def get_version_info():
"• Wishlist process API endpoint for external apps"
]
}
]
}
return jsonify(version_data)
""" # end of _OLD_V22_NOTES
_OLD_V2_NOTES = r"""
"features": [
@ -22726,7 +22866,7 @@ def _simple_monitor_task():
Search cleanup and download cleanup are now handled by system automations."""
print("Simple background monitor started")
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
try:
with matched_context_lock:
pending_count = len(matched_downloads_context)
@ -22753,6 +22893,8 @@ def _simple_monitor_task():
print(f"Simple monitor error: {e}")
time.sleep(10)
print("Simple background monitor stopped")
def start_simple_background_monitor():
"""Starts the simple background monitor thread."""
monitor_thread = threading.Thread(target=_simple_monitor_task)
@ -30419,7 +30561,7 @@ def get_server_playlists():
raw_playlists = plex_client.server.playlists()
logger.info(f"[ServerPlaylists] Plex returned {len(raw_playlists)} total playlists")
for playlist in raw_playlists:
if playlist.playlistType == 'audio':
if getattr(playlist, 'playlistType', None) == 'audio':
playlists_data.append({
'id': str(playlist.ratingKey),
'name': playlist.title,
@ -40717,6 +40859,7 @@ metadata_update_state = {
}
metadata_update_worker = None
metadata_update_runtime_worker = None
metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="metadata_update")
# ===============================
@ -44831,7 +44974,7 @@ def _old_get_listenbrainz_playlist_tracks_DEPRECATED(playlist_mbid):
@app.route('/api/metadata/start', methods=['POST'])
def start_metadata_update():
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
global metadata_update_worker, metadata_update_state
global metadata_update_worker, metadata_update_runtime_worker, metadata_update_state
try:
# Check if already running
@ -44899,6 +45042,7 @@ def start_metadata_update():
# Start the metadata update worker - EXACTLY like dashboard.py
def run_metadata_update():
global metadata_update_runtime_worker
try:
metadata_worker = WebMetadataUpdateWorker(
None, # Artists will be loaded in the worker thread - EXACTLY like dashboard.py
@ -44907,12 +45051,15 @@ def start_metadata_update():
active_server,
refresh_interval_days
)
metadata_update_runtime_worker = metadata_worker
metadata_worker.run()
except Exception as e:
print(f"Error in metadata update worker: {e}")
metadata_update_state['status'] = 'error'
metadata_update_state['error'] = str(e)
add_activity_item("", "Metadata Error", str(e), "Now")
finally:
metadata_update_runtime_worker = None
metadata_update_worker = metadata_update_executor.submit(run_metadata_update)
@ -52046,7 +52193,7 @@ def _hydrabase_reconnect_loop():
global _hydrabase_ws
_consecutive_failures = 0
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(30)
try:
# Only attempt reconnect if auto_connect is enabled
@ -52096,7 +52243,7 @@ def _hydrabase_reconnect_loop():
def _emit_service_status_loop():
"""Background thread that pushes service status every 5 seconds."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(5)
try:
socketio.emit('status:update', _build_status_payload())
@ -52105,7 +52252,7 @@ def _emit_service_status_loop():
def _emit_watchlist_count_loop():
"""Background thread that pushes watchlist count every 10 seconds to each profile room."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(10)
try:
database = get_database()
@ -52118,7 +52265,7 @@ def _emit_watchlist_count_loop():
def _emit_download_status_loop():
"""Background thread that pushes download batch status every 2 seconds to subscribed rooms."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2)
try:
live_transfers_lookup = get_cached_transfer_data()
@ -52179,7 +52326,7 @@ def handle_profile_join(data):
def _emit_system_stats_loop():
"""Background thread that pushes system stats every 10 seconds."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(10)
try:
socketio.emit('dashboard:stats', _build_system_stats())
@ -52188,7 +52335,7 @@ def _emit_system_stats_loop():
def _emit_activity_feed_loop():
"""Background thread that pushes activity feed every 2 seconds."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2)
try:
with activity_feed_lock:
@ -52199,7 +52346,7 @@ def _emit_activity_feed_loop():
def _emit_db_stats_loop():
"""Background thread that pushes database stats every 10 seconds."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(10)
try:
db = get_database()
@ -52210,7 +52357,7 @@ def _emit_db_stats_loop():
def _emit_wishlist_count_loop():
"""Background thread that pushes wishlist count every 10 seconds to each profile room."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(10)
try:
from core.wishlist_service import get_wishlist_service
@ -52256,7 +52403,7 @@ def _emit_rate_monitor_loop():
'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment',
}
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1)
try:
from core.api_call_tracker import api_call_tracker
@ -52326,7 +52473,7 @@ def _emit_enrichment_status_loop():
'genius-enrichment': lambda: genius_worker,
}
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2)
# Auto-pause/resume rate-limited workers during downloads
@ -52364,7 +52511,7 @@ def _emit_enrichment_status_loop():
def _emit_tool_progress_loop():
"""Background thread that pushes all tool progress statuses every 1 second."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1)
# Stream status
try:
@ -52452,7 +52599,7 @@ def handle_discovery_unsubscribe(data):
def _emit_sync_progress_loop():
"""Push sync progress to subscribed rooms every 1 second."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1)
try:
with sync_lock:
@ -52476,7 +52623,7 @@ def _emit_discovery_progress_loop():
'listenbrainz': lambda: listenbrainz_playlist_states,
'spotify_public': lambda: spotify_public_discovery_states,
}
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1)
for platform, get_states in platform_states.items():
try:
@ -52506,7 +52653,7 @@ def _emit_discovery_progress_loop():
def _emit_scan_status_loop():
"""Push watchlist and media scan status every 2 seconds."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2)
# Watchlist scan
try:
@ -52538,7 +52685,7 @@ def _emit_scan_status_loop():
def _emit_automation_progress_loop():
"""Push automation:progress events every 1 second for running automations."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1)
try:
with automation_progress_lock:
@ -52582,7 +52729,7 @@ def _emit_automation_progress_loop():
def _emit_repair_progress_loop():
"""Push repair:progress events every 1 second for running repair jobs."""
while True:
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1)
try:
if repair_worker is None:
@ -52626,12 +52773,6 @@ def _emit_repair_progress_loop():
if __name__ == '__main__':
# Initialize logging for web server
from utils.logging_config import setup_logging
log_level = config_manager.get('logging.level', 'INFO')
log_path = config_manager.get('logging.path', 'logs/app.log')
logger = setup_logging(log_level, log_path)
print("Starting SoulSync Web UI Server...")
print("Open your browser and navigate to http://127.0.0.1:8008")

View file

@ -329,7 +329,7 @@
<!-- Version Section -->
<div class="version-section">
<button class="version-button" onclick="showVersionInfo()">v2.2</button>
<button class="version-button" onclick="showVersionInfo()">v2.3</button>
</div>
<!-- Status Section -->
@ -6597,7 +6597,7 @@
<!-- Header -->
<div class="version-modal-header">
<h2 class="version-modal-title">What's New in SoulSync</h2>
<div class="version-modal-subtitle">Version 2.1 — Latest Changes</div>
<div class="version-modal-subtitle">Version 2.3 — Latest Changes</div>
</div>
<!-- Content Area with Scroll -->

View file

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

View file

@ -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 = [];
@ -6704,9 +6710,13 @@ function renderMusicPaths(paths) {
container.innerHTML = paths.map((p, i) => `
<div class="form-group music-path-row" style="margin-bottom: 4px;">
<input type="text" class="music-path-input" value="${escapeHtml(p)}" placeholder="/music or C:\\Music" style="flex:1;">
<button class="test-button" onclick="this.closest('.music-path-row').remove()" style="padding: 8px 12px; color: #ef5350; border-color: rgba(239,83,80,0.3);">&times;</button>
<button class="test-button" onclick="_removeMusicPathRow(this)" style="padding: 8px 12px; color: #ef5350; border-color: rgba(239,83,80,0.3);">&times;</button>
</div>
`).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 +6730,19 @@ function addMusicPathRow() {
row.style.marginBottom = '4px';
row.innerHTML = `
<input type="text" class="music-path-input" value="" placeholder="/music or C:\\Music" style="flex:1;">
<button class="test-button" onclick="this.closest('.music-path-row').remove()" style="padding: 8px 12px; color: #ef5350; border-color: rgba(239,83,80,0.3);">&times;</button>
<button class="test-button" onclick="_removeMusicPathRow(this)" style="padding: 8px 12px; color: #ef5350; border-color: rgba(239,83,80,0.3);">&times;</button>
`;
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() {
@ -12108,9 +12127,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 || '';
@ -12166,7 +12189,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}`);
@ -15561,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');

View file

@ -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;
@ -11894,7 +11922,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 {